@danielsimonjr/memory-mcp 0.48.0 → 9.8.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/LICENSE +22 -0
- package/README.md +2000 -194
- package/dist/__tests__/file-path.test.js +7 -11
- package/dist/__tests__/knowledge-graph.test.js +3 -8
- package/dist/core/EntityManager.d.ts +266 -0
- package/dist/core/EntityManager.d.ts.map +1 -0
- package/dist/core/EntityManager.js +85 -133
- package/dist/core/GraphEventEmitter.d.ts +202 -0
- package/dist/core/GraphEventEmitter.d.ts.map +1 -0
- package/dist/core/GraphEventEmitter.js +346 -0
- package/dist/core/GraphStorage.d.ts +395 -0
- package/dist/core/GraphStorage.d.ts.map +1 -0
- package/dist/core/GraphStorage.js +643 -31
- package/dist/core/GraphTraversal.d.ts +141 -0
- package/dist/core/GraphTraversal.d.ts.map +1 -0
- package/dist/core/GraphTraversal.js +573 -0
- package/dist/core/HierarchyManager.d.ts +111 -0
- package/dist/core/HierarchyManager.d.ts.map +1 -0
- package/dist/{features → core}/HierarchyManager.js +14 -9
- package/dist/core/ManagerContext.d.ts +72 -0
- package/dist/core/ManagerContext.d.ts.map +1 -0
- package/dist/core/ManagerContext.js +118 -0
- package/dist/core/ObservationManager.d.ts +85 -0
- package/dist/core/ObservationManager.d.ts.map +1 -0
- package/dist/core/ObservationManager.js +51 -57
- package/dist/core/RelationManager.d.ts +131 -0
- package/dist/core/RelationManager.d.ts.map +1 -0
- package/dist/core/RelationManager.js +31 -7
- package/dist/core/SQLiteStorage.d.ts +354 -0
- package/dist/core/SQLiteStorage.d.ts.map +1 -0
- package/dist/core/SQLiteStorage.js +917 -0
- package/dist/core/StorageFactory.d.ts +45 -0
- package/dist/core/StorageFactory.d.ts.map +1 -0
- package/dist/core/StorageFactory.js +64 -0
- package/dist/core/TransactionManager.d.ts +464 -0
- package/dist/core/TransactionManager.d.ts.map +1 -0
- package/dist/core/TransactionManager.js +490 -13
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +12 -2
- package/dist/features/AnalyticsManager.d.ts +44 -0
- package/dist/features/AnalyticsManager.d.ts.map +1 -0
- package/dist/features/AnalyticsManager.js +3 -2
- package/dist/features/ArchiveManager.d.ts +133 -0
- package/dist/features/ArchiveManager.d.ts.map +1 -0
- package/dist/features/ArchiveManager.js +221 -14
- package/dist/features/CompressionManager.d.ts +117 -0
- package/dist/features/CompressionManager.d.ts.map +1 -0
- package/dist/features/CompressionManager.js +189 -20
- package/dist/features/IOManager.d.ts +225 -0
- package/dist/features/IOManager.d.ts.map +1 -0
- package/dist/features/IOManager.js +1041 -0
- package/dist/features/StreamingExporter.d.ts +123 -0
- package/dist/features/StreamingExporter.d.ts.map +1 -0
- package/dist/features/StreamingExporter.js +203 -0
- package/dist/features/TagManager.d.ts +147 -0
- package/dist/features/TagManager.d.ts.map +1 -0
- package/dist/features/index.d.ts +12 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +5 -6
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -10
- package/dist/memory.jsonl +1 -26
- package/dist/search/BasicSearch.d.ts +51 -0
- package/dist/search/BasicSearch.d.ts.map +1 -0
- package/dist/search/BasicSearch.js +9 -3
- package/dist/search/BooleanSearch.d.ts +98 -0
- package/dist/search/BooleanSearch.d.ts.map +1 -0
- package/dist/search/BooleanSearch.js +156 -9
- package/dist/search/EmbeddingService.d.ts +178 -0
- package/dist/search/EmbeddingService.d.ts.map +1 -0
- package/dist/search/EmbeddingService.js +358 -0
- package/dist/search/FuzzySearch.d.ts +118 -0
- package/dist/search/FuzzySearch.d.ts.map +1 -0
- package/dist/search/FuzzySearch.js +241 -25
- package/dist/search/QueryCostEstimator.d.ts +111 -0
- package/dist/search/QueryCostEstimator.d.ts.map +1 -0
- package/dist/search/QueryCostEstimator.js +355 -0
- package/dist/search/RankedSearch.d.ts +71 -0
- package/dist/search/RankedSearch.d.ts.map +1 -0
- package/dist/search/RankedSearch.js +54 -6
- package/dist/search/SavedSearchManager.d.ts +79 -0
- package/dist/search/SavedSearchManager.d.ts.map +1 -0
- package/dist/search/SearchFilterChain.d.ts +120 -0
- package/dist/search/SearchFilterChain.d.ts.map +1 -0
- package/dist/search/SearchFilterChain.js +2 -4
- package/dist/search/SearchManager.d.ts +326 -0
- package/dist/search/SearchManager.d.ts.map +1 -0
- package/dist/search/SearchManager.js +148 -0
- package/dist/search/SearchSuggestions.d.ts +27 -0
- package/dist/search/SearchSuggestions.d.ts.map +1 -0
- package/dist/search/SearchSuggestions.js +1 -1
- package/dist/search/SemanticSearch.d.ts +149 -0
- package/dist/search/SemanticSearch.d.ts.map +1 -0
- package/dist/search/SemanticSearch.js +323 -0
- package/dist/search/TFIDFEventSync.d.ts +85 -0
- package/dist/search/TFIDFEventSync.d.ts.map +1 -0
- package/dist/search/TFIDFEventSync.js +133 -0
- package/dist/search/TFIDFIndexManager.d.ts +151 -0
- package/dist/search/TFIDFIndexManager.d.ts.map +1 -0
- package/dist/search/TFIDFIndexManager.js +232 -17
- package/dist/search/VectorStore.d.ts +235 -0
- package/dist/search/VectorStore.d.ts.map +1 -0
- package/dist/search/VectorStore.js +311 -0
- package/dist/search/index.d.ts +21 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +12 -0
- package/dist/server/MCPServer.d.ts +21 -0
- package/dist/server/MCPServer.d.ts.map +1 -0
- package/dist/server/MCPServer.js +4 -4
- package/dist/server/responseCompressor.d.ts +94 -0
- package/dist/server/responseCompressor.d.ts.map +1 -0
- package/dist/server/responseCompressor.js +127 -0
- package/dist/server/toolDefinitions.d.ts +27 -0
- package/dist/server/toolDefinitions.d.ts.map +1 -0
- package/dist/server/toolDefinitions.js +188 -17
- package/dist/server/toolHandlers.d.ts +41 -0
- package/dist/server/toolHandlers.d.ts.map +1 -0
- package/dist/server/toolHandlers.js +467 -75
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -1
- package/dist/types/types.d.ts +1654 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/types.js +9 -0
- package/dist/utils/compressedCache.d.ts +192 -0
- package/dist/utils/compressedCache.d.ts.map +1 -0
- package/dist/utils/compressedCache.js +309 -0
- package/dist/utils/compressionUtil.d.ts +214 -0
- package/dist/utils/compressionUtil.d.ts.map +1 -0
- package/dist/utils/compressionUtil.js +247 -0
- package/dist/utils/constants.d.ts +245 -0
- package/dist/utils/constants.d.ts.map +1 -0
- package/dist/utils/constants.js +124 -0
- package/dist/utils/entityUtils.d.ts +321 -0
- package/dist/utils/entityUtils.d.ts.map +1 -0
- package/dist/utils/entityUtils.js +434 -4
- package/dist/utils/errors.d.ts +95 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +24 -0
- package/dist/utils/formatters.d.ts +145 -0
- package/dist/utils/formatters.d.ts.map +1 -0
- package/dist/utils/{paginationUtils.js → formatters.js} +54 -3
- package/dist/utils/index.d.ts +23 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +69 -31
- package/dist/utils/indexes.d.ts +270 -0
- package/dist/utils/indexes.d.ts.map +1 -0
- package/dist/utils/indexes.js +526 -0
- package/dist/utils/logger.d.ts +24 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/operationUtils.d.ts +124 -0
- package/dist/utils/operationUtils.d.ts.map +1 -0
- package/dist/utils/operationUtils.js +175 -0
- package/dist/utils/parallelUtils.d.ts +72 -0
- package/dist/utils/parallelUtils.d.ts.map +1 -0
- package/dist/utils/parallelUtils.js +169 -0
- package/dist/utils/schemas.d.ts +374 -0
- package/dist/utils/schemas.d.ts.map +1 -0
- package/dist/utils/schemas.js +302 -2
- package/dist/utils/searchAlgorithms.d.ts +99 -0
- package/dist/utils/searchAlgorithms.d.ts.map +1 -0
- package/dist/utils/searchAlgorithms.js +167 -0
- package/dist/utils/searchCache.d.ts +108 -0
- package/dist/utils/searchCache.d.ts.map +1 -0
- package/dist/utils/taskScheduler.d.ts +290 -0
- package/dist/utils/taskScheduler.d.ts.map +1 -0
- package/dist/utils/taskScheduler.js +466 -0
- package/dist/workers/index.d.ts +12 -0
- package/dist/workers/index.d.ts.map +1 -0
- package/dist/workers/index.js +9 -0
- package/dist/workers/levenshteinWorker.d.ts +60 -0
- package/dist/workers/levenshteinWorker.d.ts.map +1 -0
- package/dist/workers/levenshteinWorker.js +98 -0
- package/package.json +17 -4
- package/dist/__tests__/edge-cases/edge-cases.test.js +0 -406
- package/dist/__tests__/integration/workflows.test.js +0 -449
- package/dist/__tests__/performance/benchmarks.test.js +0 -413
- package/dist/__tests__/unit/core/EntityManager.test.js +0 -334
- package/dist/__tests__/unit/core/GraphStorage.test.js +0 -205
- package/dist/__tests__/unit/core/RelationManager.test.js +0 -274
- package/dist/__tests__/unit/features/CompressionManager.test.js +0 -350
- package/dist/__tests__/unit/search/BasicSearch.test.js +0 -311
- package/dist/__tests__/unit/search/BooleanSearch.test.js +0 -432
- package/dist/__tests__/unit/search/FuzzySearch.test.js +0 -448
- package/dist/__tests__/unit/search/RankedSearch.test.js +0 -379
- package/dist/__tests__/unit/utils/levenshtein.test.js +0 -77
- package/dist/core/KnowledgeGraphManager.js +0 -423
- package/dist/features/BackupManager.js +0 -311
- package/dist/features/ExportManager.js +0 -305
- package/dist/features/ImportExportManager.js +0 -50
- package/dist/features/ImportManager.js +0 -328
- package/dist/memory-saved-searches.jsonl +0 -0
- package/dist/memory-tag-aliases.jsonl +0 -0
- package/dist/types/analytics.types.js +0 -6
- package/dist/types/entity.types.js +0 -7
- package/dist/types/import-export.types.js +0 -7
- package/dist/types/search.types.js +0 -7
- package/dist/types/tag.types.js +0 -6
- package/dist/utils/dateUtils.js +0 -89
- package/dist/utils/filterUtils.js +0 -155
- package/dist/utils/levenshtein.js +0 -62
- package/dist/utils/pathUtils.js +0 -115
- package/dist/utils/responseFormatter.js +0 -55
- package/dist/utils/tagUtils.js +0 -107
- package/dist/utils/tfidf.js +0 -90
- package/dist/utils/validationHelper.js +0 -99
- package/dist/utils/validationUtils.js +0 -109
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite Storage
|
|
3
|
+
*
|
|
4
|
+
* Handles storage operations for the knowledge graph using better-sqlite3 (native SQLite).
|
|
5
|
+
* Implements IGraphStorage interface for storage abstraction.
|
|
6
|
+
*
|
|
7
|
+
* Benefits over sql.js (WASM):
|
|
8
|
+
* - 3-10x faster than WASM-based SQLite
|
|
9
|
+
* - Native FTS5 full-text search support
|
|
10
|
+
* - ACID transactions with proper durability
|
|
11
|
+
* - Concurrent read access support
|
|
12
|
+
* - No memory overhead from WASM runtime
|
|
13
|
+
* - Direct disk I/O (no manual export/import)
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Built-in indexes for O(1) lookups
|
|
17
|
+
* - Referential integrity with ON DELETE CASCADE
|
|
18
|
+
* - FTS5 full-text search on entity names and observations
|
|
19
|
+
*
|
|
20
|
+
* @module core/SQLiteStorage
|
|
21
|
+
*/
|
|
22
|
+
import Database from 'better-sqlite3';
|
|
23
|
+
import { Mutex } from 'async-mutex';
|
|
24
|
+
import { clearAllSearchCaches } from '../utils/searchCache.js';
|
|
25
|
+
import { NameIndex, TypeIndex } from '../utils/indexes.js';
|
|
26
|
+
/**
|
|
27
|
+
* SQLiteStorage manages persistence of the knowledge graph using native SQLite.
|
|
28
|
+
*
|
|
29
|
+
* Uses better-sqlite3 for native SQLite bindings with full FTS5 support,
|
|
30
|
+
* referential integrity, and proper ACID transactions.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const storage = new SQLiteStorage('/path/to/memory.db');
|
|
35
|
+
* await storage.ensureLoaded();
|
|
36
|
+
* const graph = await storage.loadGraph();
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export class SQLiteStorage {
|
|
40
|
+
dbFilePath;
|
|
41
|
+
/**
|
|
42
|
+
* Mutex for thread-safe access to storage operations.
|
|
43
|
+
* Prevents concurrent writes from corrupting the cache.
|
|
44
|
+
* Note: SQLite itself handles file-level locking, but we need
|
|
45
|
+
* to protect our in-memory cache and index operations.
|
|
46
|
+
*/
|
|
47
|
+
mutex = new Mutex();
|
|
48
|
+
/**
|
|
49
|
+
* SQLite database instance.
|
|
50
|
+
*/
|
|
51
|
+
db = null;
|
|
52
|
+
/**
|
|
53
|
+
* Whether the database has been initialized.
|
|
54
|
+
*/
|
|
55
|
+
initialized = false;
|
|
56
|
+
/**
|
|
57
|
+
* In-memory cache for fast read operations.
|
|
58
|
+
* Synchronized with SQLite on writes.
|
|
59
|
+
*/
|
|
60
|
+
cache = null;
|
|
61
|
+
/**
|
|
62
|
+
* O(1) entity lookup by name.
|
|
63
|
+
*/
|
|
64
|
+
nameIndex = new NameIndex();
|
|
65
|
+
/**
|
|
66
|
+
* O(1) entity lookup by type.
|
|
67
|
+
*/
|
|
68
|
+
typeIndex = new TypeIndex();
|
|
69
|
+
/**
|
|
70
|
+
* Pre-computed lowercase data for search optimization.
|
|
71
|
+
*/
|
|
72
|
+
lowercaseCache = new Map();
|
|
73
|
+
/**
|
|
74
|
+
* Pending changes counter for batching disk writes.
|
|
75
|
+
* Note: better-sqlite3 writes to disk immediately, but we track for API compatibility.
|
|
76
|
+
*/
|
|
77
|
+
pendingChanges = 0;
|
|
78
|
+
/**
|
|
79
|
+
* Phase 4 Sprint 1: Bidirectional relation cache for O(1) repeated lookups.
|
|
80
|
+
* Maps entity name -> all relations involving that entity (both incoming and outgoing).
|
|
81
|
+
*/
|
|
82
|
+
bidirectionalRelationCache = new Map();
|
|
83
|
+
/**
|
|
84
|
+
* Create a new SQLiteStorage instance.
|
|
85
|
+
*
|
|
86
|
+
* @param dbFilePath - Absolute path to the SQLite database file
|
|
87
|
+
*/
|
|
88
|
+
constructor(dbFilePath) {
|
|
89
|
+
this.dbFilePath = dbFilePath;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Initialize the database connection and schema.
|
|
93
|
+
*/
|
|
94
|
+
initialize() {
|
|
95
|
+
if (this.initialized)
|
|
96
|
+
return;
|
|
97
|
+
// Open database (creates file if it doesn't exist)
|
|
98
|
+
this.db = new Database(this.dbFilePath);
|
|
99
|
+
// Enable foreign keys and WAL mode for better performance
|
|
100
|
+
this.db.pragma('foreign_keys = ON');
|
|
101
|
+
this.db.pragma('journal_mode = WAL');
|
|
102
|
+
// Create tables and indexes
|
|
103
|
+
this.createTables();
|
|
104
|
+
// Load cache from database
|
|
105
|
+
this.loadCache();
|
|
106
|
+
this.initialized = true;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create database tables, indexes, and FTS5 virtual table.
|
|
110
|
+
*/
|
|
111
|
+
createTables() {
|
|
112
|
+
if (!this.db)
|
|
113
|
+
throw new Error('Database not initialized');
|
|
114
|
+
// Entities table with referential integrity for parentId
|
|
115
|
+
this.db.exec(`
|
|
116
|
+
CREATE TABLE IF NOT EXISTS entities (
|
|
117
|
+
name TEXT PRIMARY KEY,
|
|
118
|
+
entityType TEXT NOT NULL,
|
|
119
|
+
observations TEXT NOT NULL,
|
|
120
|
+
tags TEXT,
|
|
121
|
+
importance INTEGER,
|
|
122
|
+
parentId TEXT REFERENCES entities(name) ON DELETE SET NULL,
|
|
123
|
+
createdAt TEXT NOT NULL,
|
|
124
|
+
lastModified TEXT NOT NULL
|
|
125
|
+
)
|
|
126
|
+
`);
|
|
127
|
+
// Relations table with referential integrity (CASCADE delete)
|
|
128
|
+
this.db.exec(`
|
|
129
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
130
|
+
fromEntity TEXT NOT NULL REFERENCES entities(name) ON DELETE CASCADE,
|
|
131
|
+
toEntity TEXT NOT NULL REFERENCES entities(name) ON DELETE CASCADE,
|
|
132
|
+
relationType TEXT NOT NULL,
|
|
133
|
+
createdAt TEXT NOT NULL,
|
|
134
|
+
lastModified TEXT NOT NULL,
|
|
135
|
+
PRIMARY KEY (fromEntity, toEntity, relationType)
|
|
136
|
+
)
|
|
137
|
+
`);
|
|
138
|
+
// Indexes for fast lookups
|
|
139
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(entityType)`);
|
|
140
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_entity_parent ON entities(parentId)`);
|
|
141
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_relation_from ON relations(fromEntity)`);
|
|
142
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_relation_to ON relations(toEntity)`);
|
|
143
|
+
// Phase 4 Sprint 1: Additional indexes for range queries (O(n) -> O(log n))
|
|
144
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_entity_importance ON entities(importance)`);
|
|
145
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_entity_lastmodified ON entities(lastModified)`);
|
|
146
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_entity_createdat ON entities(createdAt)`);
|
|
147
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relationType)`);
|
|
148
|
+
// Composite index for common query patterns (type + importance filtering)
|
|
149
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_entity_type_importance ON entities(entityType, importance)`);
|
|
150
|
+
// FTS5 virtual table for full-text search
|
|
151
|
+
// content='' makes it an external content table (we manage content ourselves)
|
|
152
|
+
this.db.exec(`
|
|
153
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entities_fts USING fts5(
|
|
154
|
+
name,
|
|
155
|
+
entityType,
|
|
156
|
+
observations,
|
|
157
|
+
tags,
|
|
158
|
+
content='entities',
|
|
159
|
+
content_rowid='rowid'
|
|
160
|
+
)
|
|
161
|
+
`);
|
|
162
|
+
// Triggers to keep FTS5 index in sync with entities table
|
|
163
|
+
this.db.exec(`
|
|
164
|
+
CREATE TRIGGER IF NOT EXISTS entities_ai AFTER INSERT ON entities BEGIN
|
|
165
|
+
INSERT INTO entities_fts(rowid, name, entityType, observations, tags)
|
|
166
|
+
VALUES (NEW.rowid, NEW.name, NEW.entityType, NEW.observations, NEW.tags);
|
|
167
|
+
END
|
|
168
|
+
`);
|
|
169
|
+
this.db.exec(`
|
|
170
|
+
CREATE TRIGGER IF NOT EXISTS entities_ad AFTER DELETE ON entities BEGIN
|
|
171
|
+
INSERT INTO entities_fts(entities_fts, rowid, name, entityType, observations, tags)
|
|
172
|
+
VALUES ('delete', OLD.rowid, OLD.name, OLD.entityType, OLD.observations, OLD.tags);
|
|
173
|
+
END
|
|
174
|
+
`);
|
|
175
|
+
this.db.exec(`
|
|
176
|
+
CREATE TRIGGER IF NOT EXISTS entities_au AFTER UPDATE ON entities BEGIN
|
|
177
|
+
INSERT INTO entities_fts(entities_fts, rowid, name, entityType, observations, tags)
|
|
178
|
+
VALUES ('delete', OLD.rowid, OLD.name, OLD.entityType, OLD.observations, OLD.tags);
|
|
179
|
+
INSERT INTO entities_fts(rowid, name, entityType, observations, tags)
|
|
180
|
+
VALUES (NEW.rowid, NEW.name, NEW.entityType, NEW.observations, NEW.tags);
|
|
181
|
+
END
|
|
182
|
+
`);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Load all data from SQLite into memory cache.
|
|
186
|
+
*/
|
|
187
|
+
loadCache() {
|
|
188
|
+
if (!this.db)
|
|
189
|
+
throw new Error('Database not initialized');
|
|
190
|
+
const entities = [];
|
|
191
|
+
const relations = [];
|
|
192
|
+
// Load entities
|
|
193
|
+
const entityRows = this.db.prepare(`SELECT * FROM entities`).all();
|
|
194
|
+
for (const row of entityRows) {
|
|
195
|
+
const entity = this.rowToEntity(row);
|
|
196
|
+
entities.push(entity);
|
|
197
|
+
this.updateLowercaseCache(entity);
|
|
198
|
+
}
|
|
199
|
+
// Load relations
|
|
200
|
+
const relationRows = this.db.prepare(`SELECT * FROM relations`).all();
|
|
201
|
+
for (const row of relationRows) {
|
|
202
|
+
relations.push(this.rowToRelation(row));
|
|
203
|
+
}
|
|
204
|
+
this.cache = { entities, relations };
|
|
205
|
+
// Build indexes for O(1) lookups
|
|
206
|
+
this.nameIndex.build(entities);
|
|
207
|
+
this.typeIndex.build(entities);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Convert a database row to an Entity object.
|
|
211
|
+
*/
|
|
212
|
+
rowToEntity(row) {
|
|
213
|
+
return {
|
|
214
|
+
name: row.name,
|
|
215
|
+
entityType: row.entityType,
|
|
216
|
+
observations: JSON.parse(row.observations),
|
|
217
|
+
tags: row.tags ? JSON.parse(row.tags) : undefined,
|
|
218
|
+
importance: row.importance ?? undefined,
|
|
219
|
+
parentId: row.parentId ?? undefined,
|
|
220
|
+
createdAt: row.createdAt,
|
|
221
|
+
lastModified: row.lastModified,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Convert a database row to a Relation object.
|
|
226
|
+
*/
|
|
227
|
+
rowToRelation(row) {
|
|
228
|
+
return {
|
|
229
|
+
from: row.fromEntity,
|
|
230
|
+
to: row.toEntity,
|
|
231
|
+
relationType: row.relationType,
|
|
232
|
+
createdAt: row.createdAt,
|
|
233
|
+
lastModified: row.lastModified,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Update lowercase cache for an entity.
|
|
238
|
+
*/
|
|
239
|
+
updateLowercaseCache(entity) {
|
|
240
|
+
this.lowercaseCache.set(entity.name, {
|
|
241
|
+
name: entity.name.toLowerCase(),
|
|
242
|
+
entityType: entity.entityType.toLowerCase(),
|
|
243
|
+
observations: entity.observations.map(o => o.toLowerCase()),
|
|
244
|
+
tags: entity.tags?.map(t => t.toLowerCase()) || [],
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// ==================== IGraphStorage Implementation ====================
|
|
248
|
+
/**
|
|
249
|
+
* Load the knowledge graph (read-only access).
|
|
250
|
+
*
|
|
251
|
+
* @returns Promise resolving to read-only knowledge graph reference
|
|
252
|
+
*/
|
|
253
|
+
async loadGraph() {
|
|
254
|
+
await this.ensureLoaded();
|
|
255
|
+
return this.cache;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Get a mutable copy of the graph for write operations.
|
|
259
|
+
*
|
|
260
|
+
* @returns Promise resolving to mutable knowledge graph copy
|
|
261
|
+
*/
|
|
262
|
+
async getGraphForMutation() {
|
|
263
|
+
await this.ensureLoaded();
|
|
264
|
+
return {
|
|
265
|
+
entities: this.cache.entities.map(e => ({
|
|
266
|
+
...e,
|
|
267
|
+
observations: [...e.observations],
|
|
268
|
+
tags: e.tags ? [...e.tags] : undefined,
|
|
269
|
+
})),
|
|
270
|
+
relations: this.cache.relations.map(r => ({ ...r })),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Ensure the storage is loaded/initialized.
|
|
275
|
+
*
|
|
276
|
+
* @returns Promise resolving when ready
|
|
277
|
+
*/
|
|
278
|
+
async ensureLoaded() {
|
|
279
|
+
if (!this.initialized) {
|
|
280
|
+
this.initialize();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Phase 4 Sprint 1: Invalidate bidirectional relation cache for an entity.
|
|
285
|
+
*
|
|
286
|
+
* @param entityName - Entity name to invalidate cache for
|
|
287
|
+
*/
|
|
288
|
+
invalidateBidirectionalCache(entityName) {
|
|
289
|
+
this.bidirectionalRelationCache.delete(entityName);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Phase 4 Sprint 1: Clear the entire bidirectional relation cache.
|
|
293
|
+
*/
|
|
294
|
+
clearBidirectionalCache() {
|
|
295
|
+
this.bidirectionalRelationCache.clear();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Save the entire knowledge graph to storage.
|
|
299
|
+
*
|
|
300
|
+
* THREAD-SAFE: Uses mutex to prevent concurrent write operations.
|
|
301
|
+
*
|
|
302
|
+
* @param graph - The knowledge graph to save
|
|
303
|
+
* @returns Promise resolving when save is complete
|
|
304
|
+
*/
|
|
305
|
+
async saveGraph(graph) {
|
|
306
|
+
await this.ensureLoaded();
|
|
307
|
+
return this.mutex.runExclusive(async () => {
|
|
308
|
+
if (!this.db)
|
|
309
|
+
throw new Error('Database not initialized');
|
|
310
|
+
// Disable foreign keys for bulk replace operation
|
|
311
|
+
// This allows inserting entities with parentId references that may not exist
|
|
312
|
+
// and relations with dangling references (which matches the original JSONL behavior)
|
|
313
|
+
this.db.pragma('foreign_keys = OFF');
|
|
314
|
+
// Use transaction for atomicity
|
|
315
|
+
const transaction = this.db.transaction(() => {
|
|
316
|
+
// Clear existing data
|
|
317
|
+
this.db.exec('DELETE FROM relations');
|
|
318
|
+
this.db.exec('DELETE FROM entities');
|
|
319
|
+
// Insert all entities
|
|
320
|
+
const entityStmt = this.db.prepare(`
|
|
321
|
+
INSERT INTO entities (name, entityType, observations, tags, importance, parentId, createdAt, lastModified)
|
|
322
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
323
|
+
`);
|
|
324
|
+
for (const entity of graph.entities) {
|
|
325
|
+
entityStmt.run(entity.name, entity.entityType, JSON.stringify(entity.observations), entity.tags ? JSON.stringify(entity.tags) : null, entity.importance ?? null, entity.parentId ?? null, entity.createdAt || new Date().toISOString(), entity.lastModified || new Date().toISOString());
|
|
326
|
+
}
|
|
327
|
+
// Insert all relations
|
|
328
|
+
const relationStmt = this.db.prepare(`
|
|
329
|
+
INSERT INTO relations (fromEntity, toEntity, relationType, createdAt, lastModified)
|
|
330
|
+
VALUES (?, ?, ?, ?, ?)
|
|
331
|
+
`);
|
|
332
|
+
for (const relation of graph.relations) {
|
|
333
|
+
relationStmt.run(relation.from, relation.to, relation.relationType, relation.createdAt || new Date().toISOString(), relation.lastModified || new Date().toISOString());
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
transaction();
|
|
337
|
+
// Re-enable foreign keys for future operations
|
|
338
|
+
this.db.pragma('foreign_keys = ON');
|
|
339
|
+
// Update cache
|
|
340
|
+
this.cache = graph;
|
|
341
|
+
this.lowercaseCache.clear();
|
|
342
|
+
for (const entity of graph.entities) {
|
|
343
|
+
this.updateLowercaseCache(entity);
|
|
344
|
+
}
|
|
345
|
+
// Rebuild indexes
|
|
346
|
+
this.nameIndex.build(graph.entities);
|
|
347
|
+
this.typeIndex.build(graph.entities);
|
|
348
|
+
this.pendingChanges = 0;
|
|
349
|
+
// Clear search caches
|
|
350
|
+
clearAllSearchCaches();
|
|
351
|
+
// Phase 4 Sprint 1: Clear bidirectional relation cache on full save
|
|
352
|
+
this.clearBidirectionalCache();
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Append a single entity to storage.
|
|
357
|
+
*
|
|
358
|
+
* THREAD-SAFE: Uses mutex to prevent concurrent write operations.
|
|
359
|
+
*
|
|
360
|
+
* @param entity - The entity to append
|
|
361
|
+
* @returns Promise resolving when append is complete
|
|
362
|
+
*/
|
|
363
|
+
async appendEntity(entity) {
|
|
364
|
+
await this.ensureLoaded();
|
|
365
|
+
return this.mutex.runExclusive(async () => {
|
|
366
|
+
if (!this.db)
|
|
367
|
+
throw new Error('Database not initialized');
|
|
368
|
+
// Use INSERT OR REPLACE to handle updates
|
|
369
|
+
const stmt = this.db.prepare(`
|
|
370
|
+
INSERT OR REPLACE INTO entities (name, entityType, observations, tags, importance, parentId, createdAt, lastModified)
|
|
371
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
372
|
+
`);
|
|
373
|
+
stmt.run(entity.name, entity.entityType, JSON.stringify(entity.observations), entity.tags ? JSON.stringify(entity.tags) : null, entity.importance ?? null, entity.parentId ?? null, entity.createdAt || new Date().toISOString(), entity.lastModified || new Date().toISOString());
|
|
374
|
+
// Update cache
|
|
375
|
+
const existingIndex = this.cache.entities.findIndex(e => e.name === entity.name);
|
|
376
|
+
if (existingIndex >= 0) {
|
|
377
|
+
this.cache.entities[existingIndex] = entity;
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
this.cache.entities.push(entity);
|
|
381
|
+
}
|
|
382
|
+
// Update indexes
|
|
383
|
+
this.nameIndex.add(entity);
|
|
384
|
+
this.typeIndex.add(entity);
|
|
385
|
+
this.updateLowercaseCache(entity);
|
|
386
|
+
clearAllSearchCaches();
|
|
387
|
+
this.pendingChanges++;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Append a single relation to storage.
|
|
392
|
+
*
|
|
393
|
+
* THREAD-SAFE: Uses mutex to prevent concurrent write operations.
|
|
394
|
+
*
|
|
395
|
+
* @param relation - The relation to append
|
|
396
|
+
* @returns Promise resolving when append is complete
|
|
397
|
+
*/
|
|
398
|
+
async appendRelation(relation) {
|
|
399
|
+
await this.ensureLoaded();
|
|
400
|
+
return this.mutex.runExclusive(async () => {
|
|
401
|
+
if (!this.db)
|
|
402
|
+
throw new Error('Database not initialized');
|
|
403
|
+
// Use INSERT OR REPLACE to handle updates
|
|
404
|
+
const stmt = this.db.prepare(`
|
|
405
|
+
INSERT OR REPLACE INTO relations (fromEntity, toEntity, relationType, createdAt, lastModified)
|
|
406
|
+
VALUES (?, ?, ?, ?, ?)
|
|
407
|
+
`);
|
|
408
|
+
stmt.run(relation.from, relation.to, relation.relationType, relation.createdAt || new Date().toISOString(), relation.lastModified || new Date().toISOString());
|
|
409
|
+
// Update cache
|
|
410
|
+
const existingIndex = this.cache.relations.findIndex(r => r.from === relation.from && r.to === relation.to && r.relationType === relation.relationType);
|
|
411
|
+
if (existingIndex >= 0) {
|
|
412
|
+
this.cache.relations[existingIndex] = relation;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
this.cache.relations.push(relation);
|
|
416
|
+
}
|
|
417
|
+
clearAllSearchCaches();
|
|
418
|
+
// Phase 4 Sprint 1: Invalidate bidirectional cache for both entities
|
|
419
|
+
this.invalidateBidirectionalCache(relation.from);
|
|
420
|
+
this.invalidateBidirectionalCache(relation.to);
|
|
421
|
+
this.pendingChanges++;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Update an entity in storage.
|
|
426
|
+
*
|
|
427
|
+
* THREAD-SAFE: Uses mutex to prevent concurrent write operations.
|
|
428
|
+
*
|
|
429
|
+
* @param entityName - Name of the entity to update
|
|
430
|
+
* @param updates - Partial entity updates to apply
|
|
431
|
+
* @returns Promise resolving to true if found and updated
|
|
432
|
+
*/
|
|
433
|
+
async updateEntity(entityName, updates) {
|
|
434
|
+
await this.ensureLoaded();
|
|
435
|
+
return this.mutex.runExclusive(async () => {
|
|
436
|
+
if (!this.db)
|
|
437
|
+
throw new Error('Database not initialized');
|
|
438
|
+
// Find entity in cache using index (O(1))
|
|
439
|
+
const entity = this.nameIndex.get(entityName);
|
|
440
|
+
if (!entity) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
// Track old type for index update
|
|
444
|
+
const oldType = entity.entityType;
|
|
445
|
+
// Apply updates to cached entity
|
|
446
|
+
Object.assign(entity, updates);
|
|
447
|
+
entity.lastModified = new Date().toISOString();
|
|
448
|
+
// Update in database
|
|
449
|
+
const stmt = this.db.prepare(`
|
|
450
|
+
UPDATE entities SET
|
|
451
|
+
entityType = ?,
|
|
452
|
+
observations = ?,
|
|
453
|
+
tags = ?,
|
|
454
|
+
importance = ?,
|
|
455
|
+
parentId = ?,
|
|
456
|
+
lastModified = ?
|
|
457
|
+
WHERE name = ?
|
|
458
|
+
`);
|
|
459
|
+
stmt.run(entity.entityType, JSON.stringify(entity.observations), entity.tags ? JSON.stringify(entity.tags) : null, entity.importance ?? null, entity.parentId ?? null, entity.lastModified, entityName);
|
|
460
|
+
// Update indexes
|
|
461
|
+
this.nameIndex.add(entity); // Update reference
|
|
462
|
+
if (updates.entityType && updates.entityType !== oldType) {
|
|
463
|
+
this.typeIndex.updateType(entityName, oldType, updates.entityType);
|
|
464
|
+
}
|
|
465
|
+
this.updateLowercaseCache(entity);
|
|
466
|
+
clearAllSearchCaches();
|
|
467
|
+
this.pendingChanges++;
|
|
468
|
+
return true;
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Compact the storage (runs VACUUM to reclaim space).
|
|
473
|
+
*
|
|
474
|
+
* THREAD-SAFE: Uses mutex to prevent concurrent operations.
|
|
475
|
+
*
|
|
476
|
+
* @returns Promise resolving when compaction is complete
|
|
477
|
+
*/
|
|
478
|
+
async compact() {
|
|
479
|
+
await this.ensureLoaded();
|
|
480
|
+
return this.mutex.runExclusive(async () => {
|
|
481
|
+
if (!this.db)
|
|
482
|
+
return;
|
|
483
|
+
// Run SQLite VACUUM to reclaim space and defragment
|
|
484
|
+
this.db.exec('VACUUM');
|
|
485
|
+
// Rebuild FTS index for optimal search performance
|
|
486
|
+
this.db.exec(`INSERT INTO entities_fts(entities_fts) VALUES('rebuild')`);
|
|
487
|
+
this.pendingChanges = 0;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Clear any in-memory cache.
|
|
492
|
+
*/
|
|
493
|
+
clearCache() {
|
|
494
|
+
this.cache = null;
|
|
495
|
+
this.nameIndex.clear();
|
|
496
|
+
this.typeIndex.clear();
|
|
497
|
+
this.lowercaseCache.clear();
|
|
498
|
+
// Phase 4 Sprint 1: Clear bidirectional relation cache
|
|
499
|
+
this.bidirectionalRelationCache.clear();
|
|
500
|
+
this.initialized = false;
|
|
501
|
+
if (this.db) {
|
|
502
|
+
this.db.close();
|
|
503
|
+
this.db = null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// ==================== Index Operations ====================
|
|
507
|
+
/**
|
|
508
|
+
* Get an entity by name in O(1) time.
|
|
509
|
+
*
|
|
510
|
+
* OPTIMIZED: Uses NameIndex for constant-time lookup.
|
|
511
|
+
*
|
|
512
|
+
* @param name - Entity name to look up
|
|
513
|
+
* @returns Entity if found, undefined otherwise
|
|
514
|
+
*/
|
|
515
|
+
getEntityByName(name) {
|
|
516
|
+
return this.nameIndex.get(name);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Check if an entity exists by name in O(1) time.
|
|
520
|
+
*
|
|
521
|
+
* @param name - Entity name to check
|
|
522
|
+
* @returns True if entity exists
|
|
523
|
+
*/
|
|
524
|
+
hasEntity(name) {
|
|
525
|
+
return this.nameIndex.has(name);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Get all entities of a given type in O(1) time.
|
|
529
|
+
*
|
|
530
|
+
* OPTIMIZED: Uses TypeIndex for constant-time lookup of entity names,
|
|
531
|
+
* then uses NameIndex for O(1) entity retrieval.
|
|
532
|
+
*
|
|
533
|
+
* @param entityType - Entity type to filter by (case-insensitive)
|
|
534
|
+
* @returns Array of entities with the given type
|
|
535
|
+
*/
|
|
536
|
+
getEntitiesByType(entityType) {
|
|
537
|
+
const names = this.typeIndex.getNames(entityType);
|
|
538
|
+
const entities = [];
|
|
539
|
+
for (const name of names) {
|
|
540
|
+
const entity = this.nameIndex.get(name);
|
|
541
|
+
if (entity) {
|
|
542
|
+
entities.push(entity);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return entities;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Get all unique entity types in the graph.
|
|
549
|
+
*
|
|
550
|
+
* @returns Array of unique entity types (lowercase)
|
|
551
|
+
*/
|
|
552
|
+
getEntityTypes() {
|
|
553
|
+
return this.typeIndex.getTypes();
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Get pre-computed lowercase data for an entity.
|
|
557
|
+
*
|
|
558
|
+
* @param entityName - Entity name to get lowercase data for
|
|
559
|
+
* @returns LowercaseData if entity exists, undefined otherwise
|
|
560
|
+
*/
|
|
561
|
+
getLowercased(entityName) {
|
|
562
|
+
return this.lowercaseCache.get(entityName);
|
|
563
|
+
}
|
|
564
|
+
// ==================== FTS5 Full-Text Search ====================
|
|
565
|
+
/**
|
|
566
|
+
* Perform full-text search using FTS5.
|
|
567
|
+
*
|
|
568
|
+
* @param query - Search query (supports FTS5 query syntax)
|
|
569
|
+
* @returns Array of matching entity names with relevance scores
|
|
570
|
+
*/
|
|
571
|
+
fullTextSearch(query) {
|
|
572
|
+
if (!this.db || !this.initialized)
|
|
573
|
+
return [];
|
|
574
|
+
try {
|
|
575
|
+
// Use FTS5 MATCH for full-text search with BM25 ranking
|
|
576
|
+
const stmt = this.db.prepare(`
|
|
577
|
+
SELECT name, bm25(entities_fts, 10, 5, 3, 1) as score
|
|
578
|
+
FROM entities_fts
|
|
579
|
+
WHERE entities_fts MATCH ?
|
|
580
|
+
ORDER BY score
|
|
581
|
+
LIMIT 100
|
|
582
|
+
`);
|
|
583
|
+
const results = stmt.all(query);
|
|
584
|
+
return results;
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// If FTS query fails (invalid syntax), fall back to empty results
|
|
588
|
+
return [];
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Perform a simple text search (LIKE-based, case-insensitive).
|
|
593
|
+
*
|
|
594
|
+
* @param searchTerm - Term to search for
|
|
595
|
+
* @returns Array of matching entity names
|
|
596
|
+
*/
|
|
597
|
+
simpleSearch(searchTerm) {
|
|
598
|
+
if (!this.db || !this.initialized)
|
|
599
|
+
return [];
|
|
600
|
+
const pattern = `%${searchTerm}%`;
|
|
601
|
+
const stmt = this.db.prepare(`
|
|
602
|
+
SELECT name FROM entities
|
|
603
|
+
WHERE name LIKE ? COLLATE NOCASE
|
|
604
|
+
OR entityType LIKE ? COLLATE NOCASE
|
|
605
|
+
OR observations LIKE ? COLLATE NOCASE
|
|
606
|
+
OR tags LIKE ? COLLATE NOCASE
|
|
607
|
+
`);
|
|
608
|
+
const results = stmt.all(pattern, pattern, pattern, pattern);
|
|
609
|
+
return results.map(r => r.name);
|
|
610
|
+
}
|
|
611
|
+
// ==================== Utility Operations ====================
|
|
612
|
+
/**
|
|
613
|
+
* Get the storage path/location.
|
|
614
|
+
*
|
|
615
|
+
* @returns The storage path
|
|
616
|
+
*/
|
|
617
|
+
getFilePath() {
|
|
618
|
+
return this.dbFilePath;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Get the current pending changes count.
|
|
622
|
+
*
|
|
623
|
+
* @returns Number of pending changes since last reset
|
|
624
|
+
*/
|
|
625
|
+
getPendingAppends() {
|
|
626
|
+
return this.pendingChanges;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Force persistence to disk (no-op for better-sqlite3 as it writes immediately).
|
|
630
|
+
*
|
|
631
|
+
* @returns Promise resolving when persistence is complete
|
|
632
|
+
*/
|
|
633
|
+
async flush() {
|
|
634
|
+
// better-sqlite3 writes to disk immediately, but we run a checkpoint for WAL mode
|
|
635
|
+
if (this.db) {
|
|
636
|
+
this.db.pragma('wal_checkpoint(TRUNCATE)');
|
|
637
|
+
}
|
|
638
|
+
this.pendingChanges = 0;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Close the database connection.
|
|
642
|
+
*/
|
|
643
|
+
close() {
|
|
644
|
+
if (this.db) {
|
|
645
|
+
this.db.close();
|
|
646
|
+
this.db = null;
|
|
647
|
+
}
|
|
648
|
+
this.initialized = false;
|
|
649
|
+
}
|
|
650
|
+
// ==================== Relation Index Operations ====================
|
|
651
|
+
/**
|
|
652
|
+
* Get all relations where the entity is the source (outgoing relations).
|
|
653
|
+
*
|
|
654
|
+
* OPTIMIZED: Uses SQLite index on fromEntity for O(log n) lookup.
|
|
655
|
+
*
|
|
656
|
+
* @param entityName - Entity name to look up outgoing relations for
|
|
657
|
+
* @returns Array of relations where entity is the source
|
|
658
|
+
*/
|
|
659
|
+
getRelationsFrom(entityName) {
|
|
660
|
+
// Check cache first
|
|
661
|
+
if (this.cache) {
|
|
662
|
+
return this.cache.relations.filter(r => r.from === entityName);
|
|
663
|
+
}
|
|
664
|
+
// Fall back to database query
|
|
665
|
+
if (!this.db || !this.initialized)
|
|
666
|
+
return [];
|
|
667
|
+
const stmt = this.db.prepare('SELECT fromEntity, toEntity, relationType, createdAt, lastModified FROM relations WHERE fromEntity = ?');
|
|
668
|
+
const rows = stmt.all(entityName);
|
|
669
|
+
return rows.map(row => ({
|
|
670
|
+
from: row.fromEntity,
|
|
671
|
+
to: row.toEntity,
|
|
672
|
+
relationType: row.relationType,
|
|
673
|
+
createdAt: row.createdAt,
|
|
674
|
+
lastModified: row.lastModified,
|
|
675
|
+
}));
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Get all relations where the entity is the target (incoming relations).
|
|
679
|
+
*
|
|
680
|
+
* OPTIMIZED: Uses SQLite index on toEntity for O(log n) lookup.
|
|
681
|
+
*
|
|
682
|
+
* @param entityName - Entity name to look up incoming relations for
|
|
683
|
+
* @returns Array of relations where entity is the target
|
|
684
|
+
*/
|
|
685
|
+
getRelationsTo(entityName) {
|
|
686
|
+
// Check cache first
|
|
687
|
+
if (this.cache) {
|
|
688
|
+
return this.cache.relations.filter(r => r.to === entityName);
|
|
689
|
+
}
|
|
690
|
+
// Fall back to database query
|
|
691
|
+
if (!this.db || !this.initialized)
|
|
692
|
+
return [];
|
|
693
|
+
const stmt = this.db.prepare('SELECT fromEntity, toEntity, relationType, createdAt, lastModified FROM relations WHERE toEntity = ?');
|
|
694
|
+
const rows = stmt.all(entityName);
|
|
695
|
+
return rows.map(row => ({
|
|
696
|
+
from: row.fromEntity,
|
|
697
|
+
to: row.toEntity,
|
|
698
|
+
relationType: row.relationType,
|
|
699
|
+
createdAt: row.createdAt,
|
|
700
|
+
lastModified: row.lastModified,
|
|
701
|
+
}));
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Get all relations involving the entity (both incoming and outgoing).
|
|
705
|
+
*
|
|
706
|
+
* OPTIMIZED: Phase 4 Sprint 1 - Uses bidirectional cache for O(1) repeated lookups.
|
|
707
|
+
*
|
|
708
|
+
* @param entityName - Entity name to look up all relations for
|
|
709
|
+
* @returns Array of all relations involving the entity
|
|
710
|
+
*/
|
|
711
|
+
getRelationsFor(entityName) {
|
|
712
|
+
// Phase 4 Sprint 1: Check bidirectional cache first for O(1) repeated lookups
|
|
713
|
+
const cached = this.bidirectionalRelationCache.get(entityName);
|
|
714
|
+
if (cached !== undefined) {
|
|
715
|
+
return cached;
|
|
716
|
+
}
|
|
717
|
+
// Check main cache and compute result
|
|
718
|
+
let relations;
|
|
719
|
+
if (this.cache) {
|
|
720
|
+
relations = this.cache.relations.filter(r => r.from === entityName || r.to === entityName);
|
|
721
|
+
}
|
|
722
|
+
else if (this.db && this.initialized) {
|
|
723
|
+
// Fall back to database query
|
|
724
|
+
const stmt = this.db.prepare('SELECT fromEntity, toEntity, relationType, createdAt, lastModified FROM relations WHERE fromEntity = ? OR toEntity = ?');
|
|
725
|
+
const rows = stmt.all(entityName, entityName);
|
|
726
|
+
relations = rows.map(row => ({
|
|
727
|
+
from: row.fromEntity,
|
|
728
|
+
to: row.toEntity,
|
|
729
|
+
relationType: row.relationType,
|
|
730
|
+
createdAt: row.createdAt,
|
|
731
|
+
lastModified: row.lastModified,
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
// Cache the result for O(1) subsequent lookups
|
|
738
|
+
this.bidirectionalRelationCache.set(entityName, relations);
|
|
739
|
+
return relations;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Check if an entity has any relations.
|
|
743
|
+
*
|
|
744
|
+
* @param entityName - Entity name to check
|
|
745
|
+
* @returns True if entity has any relations
|
|
746
|
+
*/
|
|
747
|
+
hasRelations(entityName) {
|
|
748
|
+
// Check cache first
|
|
749
|
+
if (this.cache) {
|
|
750
|
+
return this.cache.relations.some(r => r.from === entityName || r.to === entityName);
|
|
751
|
+
}
|
|
752
|
+
// Fall back to database query
|
|
753
|
+
if (!this.db || !this.initialized)
|
|
754
|
+
return false;
|
|
755
|
+
const stmt = this.db.prepare('SELECT 1 FROM relations WHERE fromEntity = ? OR toEntity = ? LIMIT 1');
|
|
756
|
+
const row = stmt.get(entityName, entityName);
|
|
757
|
+
return row !== undefined;
|
|
758
|
+
}
|
|
759
|
+
// ==================== Embedding Storage (Phase 4 Sprint 11) ====================
|
|
760
|
+
/**
|
|
761
|
+
* Phase 4 Sprint 11: Ensure embeddings table exists.
|
|
762
|
+
*
|
|
763
|
+
* Creates the embeddings table if it doesn't exist.
|
|
764
|
+
* Separate table from entities to avoid schema migration complexity.
|
|
765
|
+
*/
|
|
766
|
+
ensureEmbeddingsTable() {
|
|
767
|
+
if (!this.db)
|
|
768
|
+
throw new Error('Database not initialized');
|
|
769
|
+
this.db.exec(`
|
|
770
|
+
CREATE TABLE IF NOT EXISTS embeddings (
|
|
771
|
+
entityName TEXT PRIMARY KEY REFERENCES entities(name) ON DELETE CASCADE,
|
|
772
|
+
embedding BLOB NOT NULL,
|
|
773
|
+
embeddingModel TEXT NOT NULL,
|
|
774
|
+
embeddingUpdatedAt TEXT NOT NULL,
|
|
775
|
+
dimensions INTEGER NOT NULL
|
|
776
|
+
)
|
|
777
|
+
`);
|
|
778
|
+
// Index for quick lookup by model
|
|
779
|
+
this.db.exec(`CREATE INDEX IF NOT EXISTS idx_embedding_model ON embeddings(embeddingModel)`);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Phase 4 Sprint 11: Store an embedding for an entity.
|
|
783
|
+
*
|
|
784
|
+
* @param entityName - Name of the entity
|
|
785
|
+
* @param vector - Embedding vector
|
|
786
|
+
* @param model - Model name used for the embedding
|
|
787
|
+
*/
|
|
788
|
+
storeEmbedding(entityName, vector, model) {
|
|
789
|
+
if (!this.db || !this.initialized) {
|
|
790
|
+
throw new Error('Database not initialized');
|
|
791
|
+
}
|
|
792
|
+
this.ensureEmbeddingsTable();
|
|
793
|
+
// Convert to Float32Array for efficient storage
|
|
794
|
+
const buffer = Buffer.from(new Float32Array(vector).buffer);
|
|
795
|
+
const stmt = this.db.prepare(`
|
|
796
|
+
INSERT OR REPLACE INTO embeddings (entityName, embedding, embeddingModel, embeddingUpdatedAt, dimensions)
|
|
797
|
+
VALUES (?, ?, ?, ?, ?)
|
|
798
|
+
`);
|
|
799
|
+
stmt.run(entityName, buffer, model, new Date().toISOString(), vector.length);
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Phase 4 Sprint 11: Get an embedding for an entity.
|
|
803
|
+
*
|
|
804
|
+
* @param entityName - Name of the entity
|
|
805
|
+
* @returns Embedding vector if found, null otherwise
|
|
806
|
+
*/
|
|
807
|
+
getEmbedding(entityName) {
|
|
808
|
+
if (!this.db || !this.initialized)
|
|
809
|
+
return null;
|
|
810
|
+
try {
|
|
811
|
+
this.ensureEmbeddingsTable();
|
|
812
|
+
const stmt = this.db.prepare(`SELECT embedding FROM embeddings WHERE entityName = ?`);
|
|
813
|
+
const row = stmt.get(entityName);
|
|
814
|
+
if (!row)
|
|
815
|
+
return null;
|
|
816
|
+
// Convert from Buffer to number array
|
|
817
|
+
const float32Array = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.length / 4);
|
|
818
|
+
return Array.from(float32Array);
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Phase 4 Sprint 11: Load all embeddings from storage.
|
|
826
|
+
*
|
|
827
|
+
* @returns Array of [entityName, vector] pairs
|
|
828
|
+
*/
|
|
829
|
+
async loadAllEmbeddings() {
|
|
830
|
+
if (!this.db || !this.initialized)
|
|
831
|
+
return [];
|
|
832
|
+
try {
|
|
833
|
+
this.ensureEmbeddingsTable();
|
|
834
|
+
const stmt = this.db.prepare(`SELECT entityName, embedding FROM embeddings`);
|
|
835
|
+
const rows = stmt.all();
|
|
836
|
+
return rows.map(row => {
|
|
837
|
+
const float32Array = new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.length / 4);
|
|
838
|
+
return [row.entityName, Array.from(float32Array)];
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
return [];
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Phase 4 Sprint 11: Remove an embedding for an entity.
|
|
847
|
+
*
|
|
848
|
+
* @param entityName - Name of the entity
|
|
849
|
+
*/
|
|
850
|
+
removeEmbedding(entityName) {
|
|
851
|
+
if (!this.db || !this.initialized)
|
|
852
|
+
return;
|
|
853
|
+
try {
|
|
854
|
+
this.ensureEmbeddingsTable();
|
|
855
|
+
const stmt = this.db.prepare(`DELETE FROM embeddings WHERE entityName = ?`);
|
|
856
|
+
stmt.run(entityName);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// Ignore errors if table doesn't exist
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Phase 4 Sprint 11: Clear all embeddings from storage.
|
|
864
|
+
*/
|
|
865
|
+
clearAllEmbeddings() {
|
|
866
|
+
if (!this.db || !this.initialized)
|
|
867
|
+
return;
|
|
868
|
+
try {
|
|
869
|
+
this.ensureEmbeddingsTable();
|
|
870
|
+
this.db.exec(`DELETE FROM embeddings`);
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
// Ignore errors if table doesn't exist
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Phase 4 Sprint 11: Check if an entity has an embedding.
|
|
878
|
+
*
|
|
879
|
+
* @param entityName - Name of the entity
|
|
880
|
+
* @returns True if embedding exists
|
|
881
|
+
*/
|
|
882
|
+
hasEmbedding(entityName) {
|
|
883
|
+
if (!this.db || !this.initialized)
|
|
884
|
+
return false;
|
|
885
|
+
try {
|
|
886
|
+
this.ensureEmbeddingsTable();
|
|
887
|
+
const stmt = this.db.prepare(`SELECT 1 FROM embeddings WHERE entityName = ? LIMIT 1`);
|
|
888
|
+
const row = stmt.get(entityName);
|
|
889
|
+
return row !== undefined;
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Phase 4 Sprint 11: Get embedding statistics.
|
|
897
|
+
*
|
|
898
|
+
* @returns Stats about stored embeddings
|
|
899
|
+
*/
|
|
900
|
+
getEmbeddingStats() {
|
|
901
|
+
if (!this.db || !this.initialized) {
|
|
902
|
+
return { count: 0, models: [] };
|
|
903
|
+
}
|
|
904
|
+
try {
|
|
905
|
+
this.ensureEmbeddingsTable();
|
|
906
|
+
const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM embeddings`).get();
|
|
907
|
+
const modelRows = this.db.prepare(`SELECT DISTINCT embeddingModel FROM embeddings`).all();
|
|
908
|
+
return {
|
|
909
|
+
count: countRow.count,
|
|
910
|
+
models: modelRows.map(r => r.embeddingModel),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
return { count: 0, models: [] };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|