learngraph 0.1.0 → 0.2.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.
Files changed (99) hide show
  1. package/LICENSE +190 -21
  2. package/README.md +187 -5
  3. package/dist/cjs/index.js +1 -1
  4. package/dist/cjs/parsers/base.js +189 -0
  5. package/dist/cjs/parsers/base.js.map +1 -0
  6. package/dist/cjs/parsers/demo.js +159 -0
  7. package/dist/cjs/parsers/demo.js.map +1 -0
  8. package/dist/cjs/parsers/extractor.js +191 -0
  9. package/dist/cjs/parsers/extractor.js.map +1 -0
  10. package/dist/cjs/parsers/index.js +43 -4
  11. package/dist/cjs/parsers/index.js.map +1 -1
  12. package/dist/cjs/parsers/json.js +157 -0
  13. package/dist/cjs/parsers/json.js.map +1 -0
  14. package/dist/cjs/parsers/markdown.js +168 -0
  15. package/dist/cjs/parsers/markdown.js.map +1 -0
  16. package/dist/cjs/parsers/samples.js +139 -0
  17. package/dist/cjs/parsers/samples.js.map +1 -0
  18. package/dist/cjs/storage/base.js +231 -0
  19. package/dist/cjs/storage/base.js.map +1 -0
  20. package/dist/cjs/storage/errors.js +128 -0
  21. package/dist/cjs/storage/errors.js.map +1 -0
  22. package/dist/cjs/storage/index.js +92 -5
  23. package/dist/cjs/storage/index.js.map +1 -1
  24. package/dist/cjs/storage/levelgraph.js +855 -0
  25. package/dist/cjs/storage/levelgraph.js.map +1 -0
  26. package/dist/cjs/storage/memory.js +447 -0
  27. package/dist/cjs/storage/memory.js.map +1 -0
  28. package/dist/cjs/storage/neo4j.js +866 -0
  29. package/dist/cjs/storage/neo4j.js.map +1 -0
  30. package/dist/cjs/storage/seeds.js +565 -0
  31. package/dist/cjs/storage/seeds.js.map +1 -0
  32. package/dist/cjs/types/parser.js +8 -0
  33. package/dist/cjs/types/parser.js.map +1 -0
  34. package/dist/esm/index.js +1 -1
  35. package/dist/esm/parsers/base.js +179 -0
  36. package/dist/esm/parsers/base.js.map +1 -0
  37. package/dist/esm/parsers/demo.js +154 -0
  38. package/dist/esm/parsers/demo.js.map +1 -0
  39. package/dist/esm/parsers/extractor.js +187 -0
  40. package/dist/esm/parsers/extractor.js.map +1 -0
  41. package/dist/esm/parsers/index.js +24 -5
  42. package/dist/esm/parsers/index.js.map +1 -1
  43. package/dist/esm/parsers/json.js +153 -0
  44. package/dist/esm/parsers/json.js.map +1 -0
  45. package/dist/esm/parsers/markdown.js +164 -0
  46. package/dist/esm/parsers/markdown.js.map +1 -0
  47. package/dist/esm/parsers/samples.js +136 -0
  48. package/dist/esm/parsers/samples.js.map +1 -0
  49. package/dist/esm/storage/base.js +221 -0
  50. package/dist/esm/storage/base.js.map +1 -0
  51. package/dist/esm/storage/errors.js +116 -0
  52. package/dist/esm/storage/errors.js.map +1 -0
  53. package/dist/esm/storage/index.js +71 -6
  54. package/dist/esm/storage/index.js.map +1 -1
  55. package/dist/esm/storage/levelgraph.js +818 -0
  56. package/dist/esm/storage/levelgraph.js.map +1 -0
  57. package/dist/esm/storage/memory.js +443 -0
  58. package/dist/esm/storage/memory.js.map +1 -0
  59. package/dist/esm/storage/neo4j.js +829 -0
  60. package/dist/esm/storage/neo4j.js.map +1 -0
  61. package/dist/esm/storage/seeds.js +561 -0
  62. package/dist/esm/storage/seeds.js.map +1 -0
  63. package/dist/esm/types/parser.js +7 -0
  64. package/dist/esm/types/parser.js.map +1 -0
  65. package/dist/types/index.d.ts +1 -1
  66. package/dist/types/parsers/base.d.ts +39 -0
  67. package/dist/types/parsers/base.d.ts.map +1 -0
  68. package/dist/types/parsers/demo.d.ts +87 -0
  69. package/dist/types/parsers/demo.d.ts.map +1 -0
  70. package/dist/types/parsers/extractor.d.ts +43 -0
  71. package/dist/types/parsers/extractor.d.ts.map +1 -0
  72. package/dist/types/parsers/index.d.ts +10 -0
  73. package/dist/types/parsers/index.d.ts.map +1 -1
  74. package/dist/types/parsers/json.d.ts +71 -0
  75. package/dist/types/parsers/json.d.ts.map +1 -0
  76. package/dist/types/parsers/markdown.d.ts +43 -0
  77. package/dist/types/parsers/markdown.d.ts.map +1 -0
  78. package/dist/types/parsers/samples.d.ts +27 -0
  79. package/dist/types/parsers/samples.d.ts.map +1 -0
  80. package/dist/types/storage/base.d.ts +39 -0
  81. package/dist/types/storage/base.d.ts.map +1 -0
  82. package/dist/types/storage/errors.d.ts +74 -0
  83. package/dist/types/storage/errors.d.ts.map +1 -0
  84. package/dist/types/storage/index.d.ts +50 -2
  85. package/dist/types/storage/index.d.ts.map +1 -1
  86. package/dist/types/storage/levelgraph.d.ts +92 -0
  87. package/dist/types/storage/levelgraph.d.ts.map +1 -0
  88. package/dist/types/storage/memory.d.ts +70 -0
  89. package/dist/types/storage/memory.d.ts.map +1 -0
  90. package/dist/types/storage/neo4j.d.ts +88 -0
  91. package/dist/types/storage/neo4j.d.ts.map +1 -0
  92. package/dist/types/storage/seeds.d.ts +27 -0
  93. package/dist/types/storage/seeds.d.ts.map +1 -0
  94. package/dist/types/types/index.d.ts +1 -0
  95. package/dist/types/types/index.d.ts.map +1 -1
  96. package/dist/types/types/parser.d.ts +208 -0
  97. package/dist/types/types/parser.d.ts.map +1 -0
  98. package/package.json +33 -6
  99. package/scripts/postinstall.js +68 -0
@@ -0,0 +1,829 @@
1
+ /**
2
+ * Neo4j storage adapter
3
+ *
4
+ * Production-ready graph storage using Neo4j graph database
5
+ * with full Cypher query support and connection pooling.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ import { GRAPH_VERSION, createSkillId, createEdgeId } from '../types/index.js';
10
+ import { NotFoundError, DuplicateError, ReferenceError as StorageReferenceError, ConnectionError, } from './errors.js';
11
+ import { nowISO, inputToSkillNode, inputToEdge, validateSkillInput, validateEdgeInput, requireConnection, calculateStats, } from './base.js';
12
+ /**
13
+ * Neo4j storage adapter.
14
+ *
15
+ * Production-ready graph storage with full CRUD operations,
16
+ * connection pooling, and optimized Cypher queries.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * import { Neo4jStorage } from 'learngraph/storage';
21
+ *
22
+ * const storage = new Neo4jStorage();
23
+ * await storage.connect({
24
+ * backend: 'neo4j',
25
+ * uri: 'bolt://localhost:7687',
26
+ * username: 'neo4j',
27
+ * password: 'password',
28
+ * database: 'learngraph',
29
+ * });
30
+ *
31
+ * // Create a skill
32
+ * const skill = await storage.createSkill({
33
+ * name: 'Add Fractions',
34
+ * description: 'Add fractions with like denominators',
35
+ * bloomLevel: 'apply',
36
+ * difficulty: 0.4,
37
+ * isThresholdConcept: false,
38
+ * masteryThreshold: 0.8,
39
+ * estimatedMinutes: 30,
40
+ * tags: ['math', 'fractions'],
41
+ * metadata: {},
42
+ * });
43
+ *
44
+ * // Query the graph
45
+ * const prerequisites = await storage.getPrerequisitesOf(skill.id);
46
+ * ```
47
+ */
48
+ export class Neo4jStorage {
49
+ driver = null;
50
+ config = null;
51
+ _connected = false;
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // Session Management
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ getSession() {
56
+ if (!this.driver || !this._connected) {
57
+ throw new ConnectionError('Not connected to Neo4j');
58
+ }
59
+ return this.driver.session({
60
+ database: this.config?.database || 'neo4j',
61
+ });
62
+ }
63
+ async runQuery(query, params) {
64
+ const session = this.getSession();
65
+ try {
66
+ return await session.run(query, params);
67
+ }
68
+ finally {
69
+ await session.close();
70
+ }
71
+ }
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+ // Record Conversion
74
+ // ─────────────────────────────────────────────────────────────────────────────
75
+ recordToSkill(record) {
76
+ const s = record;
77
+ const node = {
78
+ id: createSkillId(s.id),
79
+ name: s.name,
80
+ description: s.description,
81
+ bloomLevel: s.bloomLevel,
82
+ difficulty: s.difficulty,
83
+ isThresholdConcept: s.isThresholdConcept,
84
+ masteryThreshold: s.masteryThreshold,
85
+ estimatedMinutes: s.estimatedMinutes,
86
+ tags: s.tags,
87
+ metadata: typeof s.metadata === 'string' ? JSON.parse(s.metadata) : s.metadata || {},
88
+ createdAt: s.createdAt,
89
+ updatedAt: s.updatedAt,
90
+ };
91
+ // Only add optional fields if they have values
92
+ if (s.standardAlignment) {
93
+ node.standardAlignment = s.standardAlignment;
94
+ }
95
+ if (s.domain) {
96
+ node.domain = s.domain;
97
+ }
98
+ if (s.gradeLevel) {
99
+ node.gradeLevel = s.gradeLevel;
100
+ }
101
+ return node;
102
+ }
103
+ recordToEdge(record) {
104
+ const e = record;
105
+ const edge = {
106
+ id: createEdgeId(e.id),
107
+ sourceId: createSkillId(e.sourceId),
108
+ targetId: createSkillId(e.targetId),
109
+ strength: e.strength,
110
+ type: e.type,
111
+ metadata: typeof e.metadata === 'string' ? JSON.parse(e.metadata) : e.metadata || {},
112
+ createdAt: e.createdAt,
113
+ };
114
+ // Only add optional fields if they have values
115
+ if (e.reasoning) {
116
+ edge.reasoning = e.reasoning;
117
+ }
118
+ return edge;
119
+ }
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ // Connection Management
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+ async connect(config) {
124
+ if (config.backend !== 'neo4j') {
125
+ throw new Error('Neo4jStorage only supports "neo4j" backend');
126
+ }
127
+ this.config = config;
128
+ try {
129
+ // Dynamic import of neo4j-driver
130
+ const neo4j = (await import('neo4j-driver'));
131
+ this.driver = neo4j.default.driver(this.config.uri, neo4j.default.auth.basic(this.config.username, this.config.password), {
132
+ maxConnectionPoolSize: this.config.maxConnectionPoolSize || 50,
133
+ connectionTimeout: this.config.connectionTimeout || 30000,
134
+ });
135
+ // Verify connectivity
136
+ await this.driver.verifyConnectivity();
137
+ // Initialize schema (constraints and indexes)
138
+ await this.initializeSchema();
139
+ this._connected = true;
140
+ }
141
+ catch (error) {
142
+ throw new ConnectionError(`Failed to connect to Neo4j: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined);
143
+ }
144
+ }
145
+ async initializeSchema() {
146
+ const queries = [
147
+ // Skill node uniqueness constraint
148
+ `CREATE CONSTRAINT skill_id IF NOT EXISTS FOR (s:Skill) REQUIRE s.id IS UNIQUE`,
149
+ // Indexes for common queries
150
+ `CREATE INDEX skill_bloom_level IF NOT EXISTS FOR (s:Skill) ON (s.bloomLevel)`,
151
+ `CREATE INDEX skill_difficulty IF NOT EXISTS FOR (s:Skill) ON (s.difficulty)`,
152
+ `CREATE INDEX skill_threshold IF NOT EXISTS FOR (s:Skill) ON (s.isThresholdConcept)`,
153
+ `CREATE INDEX skill_domain IF NOT EXISTS FOR (s:Skill) ON (s.domain)`,
154
+ ];
155
+ for (const query of queries) {
156
+ try {
157
+ await this.runQuery(query);
158
+ }
159
+ catch {
160
+ // Ignore errors for already existing constraints/indexes
161
+ }
162
+ }
163
+ }
164
+ async disconnect() {
165
+ if (this.driver) {
166
+ await this.driver.close();
167
+ this.driver = null;
168
+ }
169
+ this._connected = false;
170
+ }
171
+ isConnected() {
172
+ return this._connected;
173
+ }
174
+ async getStatus() {
175
+ const startTime = Date.now();
176
+ if (!this._connected || !this.driver) {
177
+ return {
178
+ connected: false,
179
+ backend: 'neo4j',
180
+ lastChecked: new Date(),
181
+ error: 'Not connected',
182
+ };
183
+ }
184
+ try {
185
+ await this.driver.verifyConnectivity();
186
+ return {
187
+ connected: true,
188
+ backend: 'neo4j',
189
+ latencyMs: Date.now() - startTime,
190
+ lastChecked: new Date(),
191
+ };
192
+ }
193
+ catch (error) {
194
+ return {
195
+ connected: false,
196
+ backend: 'neo4j',
197
+ lastChecked: new Date(),
198
+ error: error instanceof Error ? error.message : String(error),
199
+ };
200
+ }
201
+ }
202
+ // ─────────────────────────────────────────────────────────────────────────────
203
+ // Skill Node CRUD
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ async createSkill(input) {
206
+ requireConnection(this._connected);
207
+ validateSkillInput(input);
208
+ const skill = inputToSkillNode(input);
209
+ const query = `
210
+ CREATE (s:Skill {
211
+ id: $id,
212
+ name: $name,
213
+ description: $description,
214
+ bloomLevel: $bloomLevel,
215
+ difficulty: $difficulty,
216
+ isThresholdConcept: $isThresholdConcept,
217
+ masteryThreshold: $masteryThreshold,
218
+ estimatedMinutes: $estimatedMinutes,
219
+ tags: $tags,
220
+ standardAlignment: $standardAlignment,
221
+ domain: $domain,
222
+ gradeLevel: $gradeLevel,
223
+ metadata: $metadata,
224
+ createdAt: $createdAt,
225
+ updatedAt: $updatedAt
226
+ })
227
+ RETURN s {.*} as skill
228
+ `;
229
+ try {
230
+ const result = await this.runQuery(query, {
231
+ id: skill.id,
232
+ name: skill.name,
233
+ description: skill.description,
234
+ bloomLevel: skill.bloomLevel,
235
+ difficulty: skill.difficulty,
236
+ isThresholdConcept: skill.isThresholdConcept,
237
+ masteryThreshold: skill.masteryThreshold,
238
+ estimatedMinutes: skill.estimatedMinutes,
239
+ tags: skill.tags,
240
+ standardAlignment: skill.standardAlignment || null,
241
+ domain: skill.domain || null,
242
+ gradeLevel: skill.gradeLevel || null,
243
+ metadata: JSON.stringify(skill.metadata),
244
+ createdAt: skill.createdAt,
245
+ updatedAt: skill.updatedAt,
246
+ });
247
+ if (result.records.length === 0) {
248
+ throw new Error('Failed to create skill');
249
+ }
250
+ return skill;
251
+ }
252
+ catch (error) {
253
+ if (error instanceof Error &&
254
+ error.message.includes('already exists')) {
255
+ throw new DuplicateError('skill', skill.id);
256
+ }
257
+ throw error;
258
+ }
259
+ }
260
+ async getSkill(id) {
261
+ requireConnection(this._connected);
262
+ const query = `
263
+ MATCH (s:Skill {id: $id})
264
+ RETURN s {.*} as skill
265
+ `;
266
+ const result = await this.runQuery(query, { id: id });
267
+ const firstRecord = result.records[0];
268
+ if (!firstRecord) {
269
+ return null;
270
+ }
271
+ return this.recordToSkill(firstRecord.get('skill'));
272
+ }
273
+ async getSkills(ids) {
274
+ requireConnection(this._connected);
275
+ if (ids.length === 0)
276
+ return [];
277
+ const query = `
278
+ MATCH (s:Skill)
279
+ WHERE s.id IN $ids
280
+ RETURN s {.*} as skill
281
+ `;
282
+ const result = await this.runQuery(query, {
283
+ ids: ids.map((id) => id),
284
+ });
285
+ return result.records.map((record) => this.recordToSkill(record.get('skill')));
286
+ }
287
+ async updateSkill(id, updates) {
288
+ requireConnection(this._connected);
289
+ const existing = await this.getSkill(id);
290
+ if (!existing) {
291
+ throw new NotFoundError('skill', id);
292
+ }
293
+ const updated = {
294
+ ...existing,
295
+ ...updates,
296
+ id: existing.id,
297
+ createdAt: existing.createdAt,
298
+ updatedAt: nowISO(),
299
+ };
300
+ const query = `
301
+ MATCH (s:Skill {id: $id})
302
+ SET s.name = $name,
303
+ s.description = $description,
304
+ s.bloomLevel = $bloomLevel,
305
+ s.difficulty = $difficulty,
306
+ s.isThresholdConcept = $isThresholdConcept,
307
+ s.masteryThreshold = $masteryThreshold,
308
+ s.estimatedMinutes = $estimatedMinutes,
309
+ s.tags = $tags,
310
+ s.standardAlignment = $standardAlignment,
311
+ s.domain = $domain,
312
+ s.gradeLevel = $gradeLevel,
313
+ s.metadata = $metadata,
314
+ s.updatedAt = $updatedAt
315
+ RETURN s {.*} as skill
316
+ `;
317
+ await this.runQuery(query, {
318
+ id: updated.id,
319
+ name: updated.name,
320
+ description: updated.description,
321
+ bloomLevel: updated.bloomLevel,
322
+ difficulty: updated.difficulty,
323
+ isThresholdConcept: updated.isThresholdConcept,
324
+ masteryThreshold: updated.masteryThreshold,
325
+ estimatedMinutes: updated.estimatedMinutes,
326
+ tags: updated.tags,
327
+ standardAlignment: updated.standardAlignment || null,
328
+ domain: updated.domain || null,
329
+ gradeLevel: updated.gradeLevel || null,
330
+ metadata: JSON.stringify(updated.metadata),
331
+ updatedAt: updated.updatedAt,
332
+ });
333
+ return updated;
334
+ }
335
+ async deleteSkill(id) {
336
+ requireConnection(this._connected);
337
+ const query = `
338
+ MATCH (s:Skill {id: $id})
339
+ DETACH DELETE s
340
+ `;
341
+ await this.runQuery(query, { id: id });
342
+ }
343
+ async findSkills(query) {
344
+ requireConnection(this._connected);
345
+ let cypher = 'MATCH (s:Skill)';
346
+ const params = {};
347
+ const whereClauses = [];
348
+ // Build WHERE clauses from filters
349
+ if (query.filters) {
350
+ const filters = query.filters;
351
+ for (let i = 0; i < filters.length; i++) {
352
+ const filter = filters[i];
353
+ const paramName = `filter${i}`;
354
+ switch (filter.operator) {
355
+ case 'eq':
356
+ whereClauses.push(`s.${filter.field} = $${paramName}`);
357
+ params[paramName] = filter.value;
358
+ break;
359
+ case 'neq':
360
+ whereClauses.push(`s.${filter.field} <> $${paramName}`);
361
+ params[paramName] = filter.value;
362
+ break;
363
+ case 'gt':
364
+ whereClauses.push(`s.${filter.field} > $${paramName}`);
365
+ params[paramName] = filter.value;
366
+ break;
367
+ case 'gte':
368
+ whereClauses.push(`s.${filter.field} >= $${paramName}`);
369
+ params[paramName] = filter.value;
370
+ break;
371
+ case 'lt':
372
+ whereClauses.push(`s.${filter.field} < $${paramName}`);
373
+ params[paramName] = filter.value;
374
+ break;
375
+ case 'lte':
376
+ whereClauses.push(`s.${filter.field} <= $${paramName}`);
377
+ params[paramName] = filter.value;
378
+ break;
379
+ case 'in':
380
+ whereClauses.push(`s.${filter.field} IN $${paramName}`);
381
+ params[paramName] = filter.value;
382
+ break;
383
+ case 'contains':
384
+ if (filter.field === 'tags') {
385
+ whereClauses.push(`$${paramName} IN s.tags`);
386
+ }
387
+ else {
388
+ whereClauses.push(`s.${filter.field} CONTAINS $${paramName}`);
389
+ }
390
+ params[paramName] = filter.value;
391
+ break;
392
+ }
393
+ }
394
+ }
395
+ if (whereClauses.length > 0) {
396
+ cypher += ' WHERE ' + whereClauses.join(' AND ');
397
+ }
398
+ cypher += ' RETURN s {.*} as skill';
399
+ // Add ORDER BY
400
+ if (query.sorting && query.sorting.length > 0) {
401
+ const orderClauses = query.sorting.map((sortItem) => `s.${sortItem.field} ${sortItem.direction.toUpperCase()}`);
402
+ cypher += ' ORDER BY ' + orderClauses.join(', ');
403
+ }
404
+ // Add pagination
405
+ if (query.pagination) {
406
+ if (query.pagination.offset) {
407
+ cypher += ` SKIP ${query.pagination.offset}`;
408
+ }
409
+ cypher += ` LIMIT ${query.pagination.limit || 100}`;
410
+ }
411
+ const result = await this.runQuery(cypher, params);
412
+ return result.records.map((record) => this.recordToSkill(record.get('skill')));
413
+ }
414
+ async countSkills(query) {
415
+ requireConnection(this._connected);
416
+ if (!query || !query.filters || query.filters.length === 0) {
417
+ const result = await this.runQuery('MATCH (s:Skill) RETURN count(s) as count');
418
+ const firstRecord = result.records[0];
419
+ return firstRecord ? firstRecord.get('count').low : 0;
420
+ }
421
+ const { pagination, ...queryWithoutPagination } = query;
422
+ const skills = await this.findSkills(queryWithoutPagination);
423
+ return skills.length;
424
+ }
425
+ // ─────────────────────────────────────────────────────────────────────────────
426
+ // Prerequisite Edge CRUD
427
+ // ─────────────────────────────────────────────────────────────────────────────
428
+ async createPrerequisite(input) {
429
+ requireConnection(this._connected);
430
+ validateEdgeInput(input);
431
+ // Verify source exists
432
+ const source = await this.getSkill(input.sourceId);
433
+ if (!source) {
434
+ throw new StorageReferenceError(input.id || 'new', input.sourceId, 'source');
435
+ }
436
+ // Verify target exists
437
+ const target = await this.getSkill(input.targetId);
438
+ if (!target) {
439
+ throw new StorageReferenceError(input.id || 'new', input.targetId, 'target');
440
+ }
441
+ const edge = inputToEdge(input);
442
+ const query = `
443
+ MATCH (source:Skill {id: $sourceId})
444
+ MATCH (target:Skill {id: $targetId})
445
+ CREATE (source)-[r:PREREQUISITE_OF {
446
+ id: $id,
447
+ strength: $strength,
448
+ type: $type,
449
+ reasoning: $reasoning,
450
+ metadata: $metadata,
451
+ createdAt: $createdAt
452
+ }]->(target)
453
+ RETURN r {.*, sourceId: source.id, targetId: target.id} as edge
454
+ `;
455
+ try {
456
+ await this.runQuery(query, {
457
+ id: edge.id,
458
+ sourceId: edge.sourceId,
459
+ targetId: edge.targetId,
460
+ strength: edge.strength,
461
+ type: edge.type,
462
+ reasoning: edge.reasoning || null,
463
+ metadata: JSON.stringify(edge.metadata),
464
+ createdAt: edge.createdAt,
465
+ });
466
+ return edge;
467
+ }
468
+ catch (error) {
469
+ if (error instanceof Error &&
470
+ error.message.includes('already exists')) {
471
+ throw new DuplicateError('edge', edge.id);
472
+ }
473
+ throw error;
474
+ }
475
+ }
476
+ async getPrerequisite(id) {
477
+ requireConnection(this._connected);
478
+ const query = `
479
+ MATCH (source:Skill)-[r:PREREQUISITE_OF {id: $id}]->(target:Skill)
480
+ RETURN r {.*, sourceId: source.id, targetId: target.id} as edge
481
+ `;
482
+ const result = await this.runQuery(query, { id: id });
483
+ const firstRecord = result.records[0];
484
+ if (!firstRecord) {
485
+ return null;
486
+ }
487
+ return this.recordToEdge(firstRecord.get('edge'));
488
+ }
489
+ async deletePrerequisite(id) {
490
+ requireConnection(this._connected);
491
+ const query = `
492
+ MATCH ()-[r:PREREQUISITE_OF {id: $id}]->()
493
+ DELETE r
494
+ `;
495
+ await this.runQuery(query, { id: id });
496
+ }
497
+ async findPrerequisites(criteria) {
498
+ requireConnection(this._connected);
499
+ let query = 'MATCH (source:Skill)-[r:PREREQUISITE_OF]->(target:Skill)';
500
+ const whereClauses = [];
501
+ const params = {};
502
+ if (criteria.sourceId) {
503
+ whereClauses.push('source.id = $sourceId');
504
+ params.sourceId = criteria.sourceId;
505
+ }
506
+ if (criteria.targetId) {
507
+ whereClauses.push('target.id = $targetId');
508
+ params.targetId = criteria.targetId;
509
+ }
510
+ if (criteria.type) {
511
+ whereClauses.push('r.type = $type');
512
+ params.type = criteria.type;
513
+ }
514
+ if (criteria.minStrength !== undefined) {
515
+ whereClauses.push('r.strength >= $minStrength');
516
+ params.minStrength = criteria.minStrength;
517
+ }
518
+ if (whereClauses.length > 0) {
519
+ query += ' WHERE ' + whereClauses.join(' AND ');
520
+ }
521
+ query += ' RETURN r {.*, sourceId: source.id, targetId: target.id} as edge';
522
+ const result = await this.runQuery(query, params);
523
+ return result.records.map((record) => this.recordToEdge(record.get('edge')));
524
+ }
525
+ // ─────────────────────────────────────────────────────────────────────────────
526
+ // Graph Traversal
527
+ // ─────────────────────────────────────────────────────────────────────────────
528
+ async getPrerequisitesOf(skillId) {
529
+ requireConnection(this._connected);
530
+ const query = `
531
+ MATCH (prereq:Skill)-[:PREREQUISITE_OF]->(s:Skill {id: $id})
532
+ RETURN prereq {.*} as skill
533
+ `;
534
+ const result = await this.runQuery(query, { id: skillId });
535
+ return result.records.map((record) => this.recordToSkill(record.get('skill')));
536
+ }
537
+ async getDependentsOf(skillId) {
538
+ requireConnection(this._connected);
539
+ const query = `
540
+ MATCH (s:Skill {id: $id})-[:PREREQUISITE_OF]->(dependent:Skill)
541
+ RETURN dependent {.*} as skill
542
+ `;
543
+ const result = await this.runQuery(query, { id: skillId });
544
+ return result.records.map((record) => this.recordToSkill(record.get('skill')));
545
+ }
546
+ async getSubgraph(rootId, depth) {
547
+ requireConnection(this._connected);
548
+ // Get all nodes within depth
549
+ const nodeQuery = `
550
+ MATCH path = (prereq:Skill)-[:PREREQUISITE_OF*0..${depth}]->(root:Skill {id: $id})
551
+ UNWIND nodes(path) as n
552
+ WITH DISTINCT n
553
+ RETURN n {.*} as skill
554
+ `;
555
+ const nodeResult = await this.runQuery(nodeQuery, { id: rootId });
556
+ const nodes = nodeResult.records.map((record) => this.recordToSkill(record.get('skill')));
557
+ const nodeIds = new Set(nodes.map((n) => n.id));
558
+ // Get edges between these nodes
559
+ const edgeQuery = `
560
+ MATCH (source:Skill)-[r:PREREQUISITE_OF]->(target:Skill)
561
+ WHERE source.id IN $nodeIds AND target.id IN $nodeIds
562
+ RETURN r {.*, sourceId: source.id, targetId: target.id} as edge
563
+ `;
564
+ const edgeResult = await this.runQuery(edgeQuery, {
565
+ nodeIds: Array.from(nodeIds),
566
+ });
567
+ const edges = edgeResult.records.map((record) => this.recordToEdge(record.get('edge')));
568
+ return {
569
+ rootId,
570
+ depth,
571
+ nodes,
572
+ edges,
573
+ };
574
+ }
575
+ async getRootSkills() {
576
+ requireConnection(this._connected);
577
+ const query = `
578
+ MATCH (s:Skill)
579
+ WHERE NOT (:Skill)-[:PREREQUISITE_OF]->(s)
580
+ RETURN s {.*} as skill
581
+ `;
582
+ const result = await this.runQuery(query);
583
+ return result.records.map((record) => this.recordToSkill(record.get('skill')));
584
+ }
585
+ async getLeafSkills() {
586
+ requireConnection(this._connected);
587
+ const query = `
588
+ MATCH (s:Skill)
589
+ WHERE NOT (s)-[:PREREQUISITE_OF]->(:Skill)
590
+ RETURN s {.*} as skill
591
+ `;
592
+ const result = await this.runQuery(query);
593
+ return result.records.map((record) => this.recordToSkill(record.get('skill')));
594
+ }
595
+ async getPath(fromId, toId) {
596
+ requireConnection(this._connected);
597
+ const query = `
598
+ MATCH path = shortestPath(
599
+ (from:Skill {id: $fromId})-[:PREREQUISITE_OF*]->(to:Skill {id: $toId})
600
+ )
601
+ UNWIND nodes(path) as n
602
+ RETURN n {.*} as skill
603
+ `;
604
+ try {
605
+ const result = await this.runQuery(query, {
606
+ fromId: fromId,
607
+ toId: toId,
608
+ });
609
+ if (result.records.length === 0) {
610
+ return null;
611
+ }
612
+ return result.records.map((record) => this.recordToSkill(record.get('skill')));
613
+ }
614
+ catch {
615
+ return null;
616
+ }
617
+ }
618
+ // ─────────────────────────────────────────────────────────────────────────────
619
+ // Bulk Operations
620
+ // ─────────────────────────────────────────────────────────────────────────────
621
+ async importGraph(nodes, edges, options) {
622
+ requireConnection(this._connected);
623
+ const startTime = Date.now();
624
+ const result = {
625
+ nodesCreated: 0,
626
+ edgesCreated: 0,
627
+ nodesSkipped: 0,
628
+ edgesSkipped: 0,
629
+ errors: [],
630
+ durationMs: 0,
631
+ };
632
+ if (options?.clearExisting) {
633
+ await this.clearAll();
634
+ }
635
+ // Batch import nodes using UNWIND for efficiency
636
+ const batchSize = 1000;
637
+ for (let i = 0; i < nodes.length; i += batchSize) {
638
+ const batch = nodes.slice(i, i + batchSize);
639
+ const skills = batch.map((input) => inputToSkillNode(input));
640
+ const query = `
641
+ UNWIND $skills as skill
642
+ MERGE (s:Skill {id: skill.id})
643
+ ON CREATE SET
644
+ s.name = skill.name,
645
+ s.description = skill.description,
646
+ s.bloomLevel = skill.bloomLevel,
647
+ s.difficulty = skill.difficulty,
648
+ s.isThresholdConcept = skill.isThresholdConcept,
649
+ s.masteryThreshold = skill.masteryThreshold,
650
+ s.estimatedMinutes = skill.estimatedMinutes,
651
+ s.tags = skill.tags,
652
+ s.standardAlignment = skill.standardAlignment,
653
+ s.domain = skill.domain,
654
+ s.gradeLevel = skill.gradeLevel,
655
+ s.metadata = skill.metadata,
656
+ s.createdAt = skill.createdAt,
657
+ s.updatedAt = skill.updatedAt
658
+ RETURN count(s) as count
659
+ `;
660
+ try {
661
+ const importResult = await this.runQuery(query, {
662
+ skills: skills.map((s) => ({
663
+ id: s.id,
664
+ name: s.name,
665
+ description: s.description,
666
+ bloomLevel: s.bloomLevel,
667
+ difficulty: s.difficulty,
668
+ isThresholdConcept: s.isThresholdConcept,
669
+ masteryThreshold: s.masteryThreshold,
670
+ estimatedMinutes: s.estimatedMinutes,
671
+ tags: s.tags,
672
+ standardAlignment: s.standardAlignment || null,
673
+ domain: s.domain || null,
674
+ gradeLevel: s.gradeLevel || null,
675
+ metadata: JSON.stringify(s.metadata),
676
+ createdAt: s.createdAt,
677
+ updatedAt: s.updatedAt,
678
+ })),
679
+ });
680
+ result.nodesCreated += importResult.summary.counters.nodesCreated();
681
+ result.nodesSkipped += batch.length - importResult.summary.counters.nodesCreated();
682
+ }
683
+ catch (error) {
684
+ for (const input of batch) {
685
+ result.errors.push({
686
+ type: 'node',
687
+ id: input.id || 'unknown',
688
+ error: error instanceof Error ? error.message : String(error),
689
+ });
690
+ }
691
+ }
692
+ }
693
+ // Batch import edges
694
+ for (let i = 0; i < edges.length; i += batchSize) {
695
+ const batch = edges.slice(i, i + batchSize);
696
+ const edgeData = batch.map((input) => inputToEdge(input));
697
+ const query = `
698
+ UNWIND $edges as edge
699
+ MATCH (source:Skill {id: edge.sourceId})
700
+ MATCH (target:Skill {id: edge.targetId})
701
+ MERGE (source)-[r:PREREQUISITE_OF {id: edge.id}]->(target)
702
+ ON CREATE SET
703
+ r.strength = edge.strength,
704
+ r.type = edge.type,
705
+ r.reasoning = edge.reasoning,
706
+ r.metadata = edge.metadata,
707
+ r.createdAt = edge.createdAt
708
+ RETURN count(r) as count
709
+ `;
710
+ try {
711
+ const importResult = await this.runQuery(query, {
712
+ edges: edgeData.map((e) => ({
713
+ id: e.id,
714
+ sourceId: e.sourceId,
715
+ targetId: e.targetId,
716
+ strength: e.strength,
717
+ type: e.type,
718
+ reasoning: e.reasoning || null,
719
+ metadata: JSON.stringify(e.metadata),
720
+ createdAt: e.createdAt,
721
+ })),
722
+ });
723
+ result.edgesCreated += importResult.summary.counters.relationshipsCreated();
724
+ result.edgesSkipped += batch.length - importResult.summary.counters.relationshipsCreated();
725
+ }
726
+ catch (error) {
727
+ for (const input of batch) {
728
+ result.errors.push({
729
+ type: 'edge',
730
+ id: input.id || 'unknown',
731
+ error: error instanceof Error ? error.message : String(error),
732
+ });
733
+ }
734
+ }
735
+ }
736
+ result.durationMs = Date.now() - startTime;
737
+ return result;
738
+ }
739
+ async exportGraph() {
740
+ requireConnection(this._connected);
741
+ const nodes = await this.findSkills({});
742
+ const edges = await this.findPrerequisites({});
743
+ return {
744
+ version: GRAPH_VERSION,
745
+ exportedAt: nowISO(),
746
+ nodes,
747
+ edges,
748
+ };
749
+ }
750
+ async clearAll() {
751
+ requireConnection(this._connected);
752
+ const query = `
753
+ MATCH (s:Skill)
754
+ DETACH DELETE s
755
+ `;
756
+ await this.runQuery(query);
757
+ }
758
+ // ─────────────────────────────────────────────────────────────────────────────
759
+ // Analytics
760
+ // ─────────────────────────────────────────────────────────────────────────────
761
+ async getStats() {
762
+ requireConnection(this._connected);
763
+ // Get basic counts
764
+ const countQuery = `
765
+ MATCH (s:Skill)
766
+ OPTIONAL MATCH ()-[r:PREREQUISITE_OF]->()
767
+ WITH
768
+ count(DISTINCT s) as nodeCount,
769
+ count(DISTINCT r) as edgeCount,
770
+ count(DISTINCT CASE WHEN s.isThresholdConcept = true THEN s END) as thresholdCount
771
+ RETURN nodeCount, edgeCount, thresholdCount
772
+ `;
773
+ const countResult = await this.runQuery(countQuery);
774
+ const countsRecord = countResult.records[0];
775
+ // Get root and leaf counts
776
+ const rootQuery = `
777
+ MATCH (s:Skill)
778
+ WHERE NOT (:Skill)-[:PREREQUISITE_OF]->(s)
779
+ RETURN count(s) as count
780
+ `;
781
+ const rootResult = await this.runQuery(rootQuery);
782
+ const rootRecord = rootResult.records[0];
783
+ const leafQuery = `
784
+ MATCH (s:Skill)
785
+ WHERE NOT (s)-[:PREREQUISITE_OF]->(:Skill)
786
+ RETURN count(s) as count
787
+ `;
788
+ const leafResult = await this.runQuery(leafQuery);
789
+ const leafRecord = leafResult.records[0];
790
+ // Get bloom distribution
791
+ const bloomQuery = `
792
+ MATCH (s:Skill)
793
+ RETURN s.bloomLevel as level, count(s) as count
794
+ `;
795
+ const bloomResult = await this.runQuery(bloomQuery);
796
+ const bloomDistribution = {
797
+ remember: 0,
798
+ understand: 0,
799
+ apply: 0,
800
+ analyze: 0,
801
+ evaluate: 0,
802
+ create: 0,
803
+ };
804
+ for (const record of bloomResult.records) {
805
+ const level = record.get('level');
806
+ const count = record.get('count').low;
807
+ if (level && bloomDistribution.hasOwnProperty(level)) {
808
+ bloomDistribution[level] = count;
809
+ }
810
+ }
811
+ // Calculate from actual data for remaining stats
812
+ const nodes = await this.findSkills({});
813
+ const edges = await this.findPrerequisites({});
814
+ const fullStats = calculateStats(nodes, edges);
815
+ return {
816
+ nodeCount: countsRecord ? countsRecord.get('nodeCount').low : 0,
817
+ edgeCount: countsRecord ? countsRecord.get('edgeCount').low : 0,
818
+ thresholdConceptCount: countsRecord ? countsRecord.get('thresholdCount').low : 0,
819
+ rootNodeCount: rootRecord ? rootRecord.get('count').low : 0,
820
+ leafNodeCount: leafRecord ? leafRecord.get('count').low : 0,
821
+ maxDepth: fullStats.maxDepth,
822
+ avgPrerequisites: fullStats.avgPrerequisites,
823
+ avgDependents: fullStats.avgDependents,
824
+ bloomDistribution,
825
+ difficultyDistribution: fullStats.difficultyDistribution,
826
+ };
827
+ }
828
+ }
829
+ //# sourceMappingURL=neo4j.js.map