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