@rool-dev/client 0.3.1-dev.e2c481d → 0.3.1-dev.ff89f50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -48
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/graph.d.ts +70 -51
- package/dist/graph.d.ts.map +1 -1
- package/dist/graph.js +326 -185
- package/dist/graph.js.map +1 -1
- package/dist/graphql.d.ts +7 -5
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +50 -17
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +76 -31
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/graph.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
2
|
// Graph
|
|
3
|
-
// First-class Graph object with
|
|
3
|
+
// First-class Graph object with object/edge operations, undo/redo, and events
|
|
4
4
|
// =============================================================================
|
|
5
5
|
import { immutableJSONPatch } from 'immutable-json-patch';
|
|
6
6
|
import { EventEmitter } from './event-emitter.js';
|
|
@@ -18,7 +18,7 @@ export function generateEntityId() {
|
|
|
18
18
|
* First-class Graph object.
|
|
19
19
|
*
|
|
20
20
|
* Features:
|
|
21
|
-
* - High-level
|
|
21
|
+
* - High-level object/edge operations
|
|
22
22
|
* - Built-in undo/redo with checkpoints
|
|
23
23
|
* - Metadata management
|
|
24
24
|
* - Event emission for state changes
|
|
@@ -36,10 +36,6 @@ export class RoolGraph extends EventEmitter {
|
|
|
36
36
|
// Undo/redo stacks
|
|
37
37
|
undoStack = [];
|
|
38
38
|
redoStack = [];
|
|
39
|
-
// Patch batching
|
|
40
|
-
pendingPatch = [];
|
|
41
|
-
patchScheduled = false;
|
|
42
|
-
patchSettled = Promise.resolve();
|
|
43
39
|
// Subscription state
|
|
44
40
|
_isSubscribed = false;
|
|
45
41
|
constructor(config) {
|
|
@@ -114,7 +110,6 @@ export class RoolGraph extends EventEmitter {
|
|
|
114
110
|
this.unsubscribe();
|
|
115
111
|
this.undoStack = [];
|
|
116
112
|
this.redoStack = [];
|
|
117
|
-
this.pendingPatch = [];
|
|
118
113
|
this.removeAllListeners();
|
|
119
114
|
}
|
|
120
115
|
// ===========================================================================
|
|
@@ -152,14 +147,11 @@ export class RoolGraph extends EventEmitter {
|
|
|
152
147
|
}
|
|
153
148
|
/**
|
|
154
149
|
* Undo to the previous checkpoint.
|
|
155
|
-
* Waits for any pending patches to settle before applying.
|
|
156
150
|
* @returns true if undo was performed
|
|
157
151
|
*/
|
|
158
152
|
async undo() {
|
|
159
153
|
if (!this.canUndo())
|
|
160
154
|
return false;
|
|
161
|
-
// Wait for pending patches before replacing state
|
|
162
|
-
await this.patchSettled;
|
|
163
155
|
// Save current state to redo stack
|
|
164
156
|
const currentEntry = {
|
|
165
157
|
timestamp: Date.now(),
|
|
@@ -173,7 +165,7 @@ export class RoolGraph extends EventEmitter {
|
|
|
173
165
|
// Sync to server - resync on failure
|
|
174
166
|
try {
|
|
175
167
|
await this.graphqlClient.setGraph(this._id, this._data);
|
|
176
|
-
this.emit('reset', { source: '
|
|
168
|
+
this.emit('reset', { source: 'local_user' });
|
|
177
169
|
}
|
|
178
170
|
catch (error) {
|
|
179
171
|
console.error('[Graph] Failed to sync undo to server:', error);
|
|
@@ -183,14 +175,11 @@ export class RoolGraph extends EventEmitter {
|
|
|
183
175
|
}
|
|
184
176
|
/**
|
|
185
177
|
* Redo a previously undone action.
|
|
186
|
-
* Waits for any pending patches to settle before applying.
|
|
187
178
|
* @returns true if redo was performed
|
|
188
179
|
*/
|
|
189
180
|
async redo() {
|
|
190
181
|
if (!this.canRedo())
|
|
191
182
|
return false;
|
|
192
|
-
// Wait for pending patches before replacing state
|
|
193
|
-
await this.patchSettled;
|
|
194
183
|
// Save current state to undo stack
|
|
195
184
|
const currentEntry = {
|
|
196
185
|
timestamp: Date.now(),
|
|
@@ -204,7 +193,7 @@ export class RoolGraph extends EventEmitter {
|
|
|
204
193
|
// Sync to server - resync on failure
|
|
205
194
|
try {
|
|
206
195
|
await this.graphqlClient.setGraph(this._id, this._data);
|
|
207
|
-
this.emit('reset', { source: '
|
|
196
|
+
this.emit('reset', { source: 'local_user' });
|
|
208
197
|
}
|
|
209
198
|
catch (error) {
|
|
210
199
|
console.error('[Graph] Failed to sync redo to server:', error);
|
|
@@ -221,213 +210,330 @@ export class RoolGraph extends EventEmitter {
|
|
|
221
210
|
this.redoStack = [];
|
|
222
211
|
}
|
|
223
212
|
// ===========================================================================
|
|
224
|
-
//
|
|
213
|
+
// Object Operations
|
|
225
214
|
// ===========================================================================
|
|
226
215
|
/**
|
|
227
|
-
* Get
|
|
228
|
-
*
|
|
216
|
+
* Get an object's data by ID.
|
|
217
|
+
* Returns just the data portion (RoolObject), not the full entry with meta/links.
|
|
218
|
+
* @throws Error if object not found
|
|
229
219
|
*/
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
if (!
|
|
233
|
-
throw new Error(`
|
|
220
|
+
getObject(objectId) {
|
|
221
|
+
const entry = this._data.objects[objectId];
|
|
222
|
+
if (!entry) {
|
|
223
|
+
throw new Error(`Object ${objectId} not found`);
|
|
234
224
|
}
|
|
235
|
-
return
|
|
225
|
+
return entry.data;
|
|
236
226
|
}
|
|
237
227
|
/**
|
|
238
|
-
* Get
|
|
228
|
+
* Get an object's data by ID, or undefined if not found.
|
|
239
229
|
*/
|
|
240
|
-
|
|
241
|
-
return this._data.
|
|
230
|
+
getObjectOrUndefined(objectId) {
|
|
231
|
+
return this._data.objects[objectId]?.data;
|
|
242
232
|
}
|
|
243
233
|
/**
|
|
244
|
-
* Get
|
|
234
|
+
* Get an object's metadata (position, UI state, etc).
|
|
245
235
|
*/
|
|
246
|
-
|
|
247
|
-
|
|
236
|
+
getObjectMeta(objectId) {
|
|
237
|
+
const entry = this._data.objects[objectId];
|
|
238
|
+
if (!entry) {
|
|
239
|
+
throw new Error(`Object ${objectId} not found`);
|
|
240
|
+
}
|
|
241
|
+
return entry.meta;
|
|
248
242
|
}
|
|
249
243
|
/**
|
|
250
|
-
* Get all
|
|
244
|
+
* Get all objects of a specific type.
|
|
251
245
|
*/
|
|
252
|
-
|
|
253
|
-
return Object.
|
|
246
|
+
getObjectsByType(type) {
|
|
247
|
+
return Object.values(this._data.objects)
|
|
248
|
+
.filter(entry => entry.data.type === type)
|
|
249
|
+
.map(entry => entry.data);
|
|
254
250
|
}
|
|
255
251
|
/**
|
|
256
|
-
*
|
|
252
|
+
* Get all object IDs.
|
|
257
253
|
*/
|
|
258
|
-
|
|
259
|
-
this._data.
|
|
260
|
-
const ops = [{ op: 'add', path: `/nodes/${nodeId}`, value: node }];
|
|
261
|
-
this.sendPatch(ops);
|
|
262
|
-
this.emit('patch', { ops, source: 'local' });
|
|
254
|
+
getObjectIds() {
|
|
255
|
+
return Object.keys(this._data.objects);
|
|
263
256
|
}
|
|
264
257
|
/**
|
|
265
|
-
*
|
|
266
|
-
* @param
|
|
267
|
-
* @param
|
|
258
|
+
* Create a new object with optional AI generation.
|
|
259
|
+
* @param options.type - The object type (required)
|
|
260
|
+
* @param options.fields - Object fields (optional). Use {{placeholder}} for AI-generated content.
|
|
261
|
+
* @param options.meta - Client-private metadata (optional). Hidden from AI operations.
|
|
262
|
+
* @param options.prompt - AI prompt for content generation (optional).
|
|
263
|
+
* @returns The generated object ID
|
|
268
264
|
*/
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
265
|
+
async createObject(options) {
|
|
266
|
+
const objectId = generateEntityId();
|
|
267
|
+
const { type, fields, meta, prompt } = options;
|
|
268
|
+
// Build the entry for local state
|
|
269
|
+
const entry = {
|
|
270
|
+
meta: meta ?? {},
|
|
271
|
+
links: {},
|
|
272
|
+
data: { type, ...fields },
|
|
273
|
+
};
|
|
274
|
+
// Update local state immediately (optimistic)
|
|
275
|
+
this._data.objects[objectId] = entry;
|
|
276
|
+
this.emit('objectCreated', { objectId, object: entry.data, source: 'local_user' });
|
|
277
|
+
// Await server call
|
|
278
|
+
try {
|
|
279
|
+
const message = await this.graphqlClient.createObject(this.id, objectId, type, fields, meta, prompt);
|
|
280
|
+
return { id: objectId, message };
|
|
273
281
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
let ops;
|
|
279
|
-
if (updateKeys.length === 1 && updateKeys[0] === '_meta' && updates._meta) {
|
|
280
|
-
// "add" is an upsert operation, so it works regardless of whether the _meta exists or not
|
|
281
|
-
ops = [{ op: 'add', path: `/nodes/${nodeId}/_meta`, value: updates._meta }];
|
|
282
|
+
catch (error) {
|
|
283
|
+
console.error('[Graph] Failed to create object:', error);
|
|
284
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
285
|
+
throw error;
|
|
282
286
|
}
|
|
283
|
-
|
|
284
|
-
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Update an existing object.
|
|
290
|
+
* @param objectId - The ID of the object to update
|
|
291
|
+
* @param options.type - Optional new type for the object
|
|
292
|
+
* @param options.fields - Fields to add or update. Use {{placeholder}} for AI-generated content.
|
|
293
|
+
* @param options.meta - Client-private metadata to merge. Hidden from AI operations.
|
|
294
|
+
* @param options.prompt - AI prompt for content editing (optional).
|
|
295
|
+
*/
|
|
296
|
+
async updateObject(objectId, options) {
|
|
297
|
+
const entry = this._data.objects[objectId];
|
|
298
|
+
if (!entry) {
|
|
299
|
+
throw new Error(`Object ${objectId} not found for update`);
|
|
300
|
+
}
|
|
301
|
+
const { type, fields, meta } = options;
|
|
302
|
+
// Build local updates
|
|
303
|
+
if (type) {
|
|
304
|
+
entry.data.type = type;
|
|
305
|
+
}
|
|
306
|
+
if (fields) {
|
|
307
|
+
Object.assign(entry.data, fields);
|
|
308
|
+
}
|
|
309
|
+
if (meta) {
|
|
310
|
+
entry.meta = { ...entry.meta, ...meta };
|
|
311
|
+
}
|
|
312
|
+
// Emit semantic event with updated object
|
|
313
|
+
if (type || fields || meta) {
|
|
314
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source: 'local_user' });
|
|
315
|
+
}
|
|
316
|
+
// Await server call
|
|
317
|
+
try {
|
|
318
|
+
return await this.graphqlClient.updateObject(this.id, objectId, type, fields, meta, options.prompt);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
console.error('[Graph] Failed to update object:', error);
|
|
322
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
323
|
+
throw error;
|
|
285
324
|
}
|
|
286
|
-
this.sendPatch(ops);
|
|
287
|
-
this.emit('patch', { ops, source: 'local' });
|
|
288
325
|
}
|
|
289
326
|
/**
|
|
290
|
-
* Delete
|
|
291
|
-
*
|
|
327
|
+
* Delete objects by IDs.
|
|
328
|
+
* Outbound links are automatically deleted with the object.
|
|
329
|
+
* Inbound links become orphans (tolerated).
|
|
292
330
|
*/
|
|
293
|
-
async
|
|
294
|
-
if (
|
|
331
|
+
async deleteObjects(objectIds) {
|
|
332
|
+
if (objectIds.length === 0)
|
|
295
333
|
return;
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
for (const
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
334
|
+
const deletedObjectIds = [];
|
|
335
|
+
// Collect edges that will be orphaned (for events)
|
|
336
|
+
const deletedEdges = [];
|
|
337
|
+
for (const objectId of objectIds) {
|
|
338
|
+
const entry = this._data.objects[objectId];
|
|
339
|
+
if (entry) {
|
|
340
|
+
// Collect outbound edges for deletion events
|
|
341
|
+
for (const [edgeType, targets] of Object.entries(entry.links)) {
|
|
342
|
+
for (const targetId of Object.keys(targets)) {
|
|
343
|
+
deletedEdges.push({ sourceId: objectId, targetId, edgeType });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
305
346
|
}
|
|
306
347
|
}
|
|
307
|
-
// Remove
|
|
308
|
-
for (const
|
|
309
|
-
if (this._data.
|
|
310
|
-
delete this._data.
|
|
311
|
-
|
|
348
|
+
// Remove objects (local state)
|
|
349
|
+
for (const objectId of objectIds) {
|
|
350
|
+
if (this._data.objects[objectId]) {
|
|
351
|
+
delete this._data.objects[objectId];
|
|
352
|
+
deletedObjectIds.push(objectId);
|
|
312
353
|
}
|
|
313
354
|
}
|
|
314
|
-
|
|
315
|
-
|
|
355
|
+
// Emit semantic events
|
|
356
|
+
for (const edge of deletedEdges) {
|
|
357
|
+
this.emit('edgeDeleted', { ...edge, source: 'local_user' });
|
|
358
|
+
}
|
|
359
|
+
for (const objectId of deletedObjectIds) {
|
|
360
|
+
this.emit('objectDeleted', { objectId, source: 'local_user' });
|
|
361
|
+
}
|
|
362
|
+
// Await server call
|
|
363
|
+
try {
|
|
364
|
+
await this.graphqlClient.deleteObjects(this.id, objectIds);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
console.error('[Graph] Failed to delete objects:', error);
|
|
368
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
369
|
+
throw error;
|
|
316
370
|
}
|
|
317
|
-
// Send to server via mutation
|
|
318
|
-
await this.graphqlClient.deleteNodes(this.id, nodeIds);
|
|
319
371
|
}
|
|
320
372
|
// ===========================================================================
|
|
321
373
|
// Edge Operations
|
|
322
374
|
// ===========================================================================
|
|
323
375
|
/**
|
|
324
|
-
* Create
|
|
325
|
-
*
|
|
376
|
+
* Create a link between objects.
|
|
377
|
+
* Links are stored on the source object.
|
|
326
378
|
*/
|
|
327
|
-
async
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
type: edgeType,
|
|
333
|
-
_meta: {},
|
|
334
|
-
};
|
|
379
|
+
async link(sourceId, targetId, edgeType) {
|
|
380
|
+
const entry = this._data.objects[sourceId];
|
|
381
|
+
if (!entry) {
|
|
382
|
+
throw new Error(`Source object ${sourceId} not found`);
|
|
383
|
+
}
|
|
335
384
|
// Update local state immediately
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
385
|
+
if (!entry.links[edgeType]) {
|
|
386
|
+
entry.links[edgeType] = {};
|
|
387
|
+
}
|
|
388
|
+
entry.links[edgeType][targetId] = {};
|
|
389
|
+
const linkData = entry.links[edgeType][targetId];
|
|
390
|
+
this.emit('edgeCreated', { sourceId, targetId, edgeType, linkData, source: 'local_user' });
|
|
391
|
+
// Await server call
|
|
392
|
+
try {
|
|
393
|
+
await this.graphqlClient.link(this.id, sourceId, targetId, edgeType);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
console.error('[Graph] Failed to create link:', error);
|
|
397
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
342
400
|
}
|
|
343
401
|
/**
|
|
344
|
-
* Remove
|
|
345
|
-
* @
|
|
402
|
+
* Remove a link between two objects.
|
|
403
|
+
* @param edgeType - Optional: if provided, only removes that type; otherwise removes all links between the objects
|
|
404
|
+
* @returns true if any links were removed
|
|
346
405
|
*/
|
|
347
|
-
async
|
|
348
|
-
const
|
|
406
|
+
async unlink(sourceId, targetId, edgeType) {
|
|
407
|
+
const entry = this._data.objects[sourceId];
|
|
408
|
+
if (!entry) {
|
|
409
|
+
throw new Error(`Source object ${sourceId} not found`);
|
|
410
|
+
}
|
|
411
|
+
const deletedEdges = [];
|
|
349
412
|
// Update local state
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
413
|
+
if (edgeType) {
|
|
414
|
+
// Remove specific edge type
|
|
415
|
+
if (entry.links[edgeType]?.[targetId] !== undefined) {
|
|
416
|
+
delete entry.links[edgeType][targetId];
|
|
417
|
+
deletedEdges.push({ edgeType });
|
|
354
418
|
}
|
|
355
419
|
}
|
|
356
|
-
|
|
357
|
-
|
|
420
|
+
else {
|
|
421
|
+
// Remove all links from source to target
|
|
422
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
423
|
+
if (targets[targetId] !== undefined) {
|
|
424
|
+
delete targets[targetId];
|
|
425
|
+
deletedEdges.push({ edgeType: type });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
// Emit semantic events
|
|
430
|
+
for (const edge of deletedEdges) {
|
|
431
|
+
this.emit('edgeDeleted', { sourceId, targetId, edgeType: edge.edgeType, source: 'local_user' });
|
|
432
|
+
}
|
|
433
|
+
// Await server call
|
|
434
|
+
try {
|
|
435
|
+
await this.graphqlClient.unlink(this.id, sourceId, targetId);
|
|
358
436
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
437
|
+
catch (error) {
|
|
438
|
+
console.error('[Graph] Failed to remove link:', error);
|
|
439
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
442
|
+
return deletedEdges.length > 0;
|
|
362
443
|
}
|
|
363
444
|
/**
|
|
364
|
-
* Get parent
|
|
445
|
+
* Get parent object IDs (objects that have edges pointing TO this object).
|
|
365
446
|
* @param edgeType - Optional filter by edge type
|
|
366
447
|
*/
|
|
367
|
-
getParents(
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
(
|
|
371
|
-
|
|
448
|
+
getParents(objectId, edgeType) {
|
|
449
|
+
const parents = [];
|
|
450
|
+
for (const [entryId, entry] of Object.entries(this._data.objects)) {
|
|
451
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
452
|
+
if ((!edgeType || type === edgeType) && objectId in targets) {
|
|
453
|
+
parents.push(entryId);
|
|
454
|
+
break; // Found a link, move to next object
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return parents;
|
|
372
459
|
}
|
|
373
460
|
/**
|
|
374
|
-
* Get child
|
|
461
|
+
* Get child object IDs (objects that this object has edges pointing TO).
|
|
462
|
+
* Filters out orphan targets (targets that don't exist).
|
|
375
463
|
* @param edgeType - Optional filter by edge type
|
|
376
464
|
*/
|
|
377
|
-
getChildren(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
465
|
+
getChildren(objectId, edgeType) {
|
|
466
|
+
const entry = this._data.objects[objectId];
|
|
467
|
+
if (!entry)
|
|
468
|
+
return [];
|
|
469
|
+
const children = [];
|
|
470
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
471
|
+
if (!edgeType || type === edgeType) {
|
|
472
|
+
for (const targetId of Object.keys(targets)) {
|
|
473
|
+
// Filter orphans - only return existing targets
|
|
474
|
+
if (this._data.objects[targetId]) {
|
|
475
|
+
children.push(targetId);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return children;
|
|
388
481
|
}
|
|
389
482
|
/**
|
|
390
|
-
* Get all
|
|
483
|
+
* Get all child object IDs including orphans (targets that may not exist).
|
|
484
|
+
* @param edgeType - Optional filter by edge type
|
|
391
485
|
*/
|
|
392
|
-
|
|
393
|
-
|
|
486
|
+
getChildrenIncludingOrphans(objectId, edgeType) {
|
|
487
|
+
const entry = this._data.objects[objectId];
|
|
488
|
+
if (!entry)
|
|
489
|
+
return [];
|
|
490
|
+
const children = [];
|
|
491
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
492
|
+
if (!edgeType || type === edgeType) {
|
|
493
|
+
children.push(...Object.keys(targets));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return children;
|
|
394
497
|
}
|
|
395
498
|
// ===========================================================================
|
|
396
499
|
// Metadata Operations
|
|
397
500
|
// ===========================================================================
|
|
398
501
|
/**
|
|
399
|
-
* Set a metadata value.
|
|
400
|
-
* Metadata is stored in
|
|
502
|
+
* Set a graph-level metadata value.
|
|
503
|
+
* Metadata is stored in meta and hidden from AI operations.
|
|
401
504
|
*/
|
|
402
505
|
setMetadata(key, value) {
|
|
403
|
-
if (!this._data.
|
|
404
|
-
this._data.
|
|
506
|
+
if (!this._data.meta) {
|
|
507
|
+
this._data.meta = {};
|
|
405
508
|
}
|
|
406
|
-
this._data.
|
|
407
|
-
|
|
408
|
-
|
|
509
|
+
this._data.meta[key] = value;
|
|
510
|
+
this.emit('metaUpdated', { meta: this._data.meta, source: 'local_user' });
|
|
511
|
+
// Fire-and-forget server call - errors trigger resync
|
|
512
|
+
this.graphqlClient.setGraphMeta(this.id, this._data.meta)
|
|
513
|
+
.catch((error) => {
|
|
514
|
+
console.error('[Graph] Failed to set graph meta:', error);
|
|
515
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
516
|
+
});
|
|
409
517
|
}
|
|
410
518
|
/**
|
|
411
|
-
* Get a metadata value.
|
|
519
|
+
* Get a graph-level metadata value.
|
|
412
520
|
*/
|
|
413
521
|
getMetadata(key) {
|
|
414
|
-
return this._data.
|
|
522
|
+
return this._data.meta?.[key];
|
|
415
523
|
}
|
|
416
524
|
/**
|
|
417
|
-
* Get all metadata.
|
|
525
|
+
* Get all graph-level metadata.
|
|
418
526
|
*/
|
|
419
527
|
getAllMetadata() {
|
|
420
|
-
return this._data.
|
|
528
|
+
return this._data.meta ?? {};
|
|
421
529
|
}
|
|
422
530
|
// ===========================================================================
|
|
423
531
|
// AI Operations
|
|
424
532
|
// ===========================================================================
|
|
425
533
|
/**
|
|
426
534
|
* Send a prompt to the AI agent for graph manipulation.
|
|
427
|
-
* Waits for any pending patches to settle before sending.
|
|
428
535
|
*/
|
|
429
536
|
async prompt(prompt, options) {
|
|
430
|
-
await this.patchSettled;
|
|
431
537
|
return this.graphqlClient.promptGraph(this._id, prompt, options);
|
|
432
538
|
}
|
|
433
539
|
// ===========================================================================
|
|
@@ -494,21 +600,6 @@ export class RoolGraph extends EventEmitter {
|
|
|
494
600
|
getData() {
|
|
495
601
|
return this._data;
|
|
496
602
|
}
|
|
497
|
-
/**
|
|
498
|
-
* Apply a raw JSON patch.
|
|
499
|
-
* Use sparingly - prefer semantic operations.
|
|
500
|
-
*/
|
|
501
|
-
patch(operations) {
|
|
502
|
-
try {
|
|
503
|
-
this._data = immutableJSONPatch(this._data, operations);
|
|
504
|
-
}
|
|
505
|
-
catch (error) {
|
|
506
|
-
console.error('[Graph] Failed to apply patch:', error);
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
this.sendPatch(operations);
|
|
510
|
-
this.emit('patch', { ops: operations, source: 'local' });
|
|
511
|
-
}
|
|
512
603
|
// ===========================================================================
|
|
513
604
|
// Event Handlers (called by RoolClient for routing)
|
|
514
605
|
// ===========================================================================
|
|
@@ -526,8 +617,84 @@ export class RoolGraph extends EventEmitter {
|
|
|
526
617
|
this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
|
|
527
618
|
return;
|
|
528
619
|
}
|
|
529
|
-
|
|
530
|
-
|
|
620
|
+
// Parse patch operations and emit semantic events
|
|
621
|
+
const changeSource = source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
622
|
+
this.emitSemanticEventsFromPatch(patch, changeSource);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Parse JSON patch operations and emit semantic events.
|
|
626
|
+
* @internal
|
|
627
|
+
*/
|
|
628
|
+
emitSemanticEventsFromPatch(patch, source) {
|
|
629
|
+
// Track which objects have been updated (to avoid duplicate events)
|
|
630
|
+
const updatedObjects = new Set();
|
|
631
|
+
for (const op of patch) {
|
|
632
|
+
const { path } = op;
|
|
633
|
+
// Object operations: /objects/{objectId}/...
|
|
634
|
+
if (path.startsWith('/objects/')) {
|
|
635
|
+
const parts = path.split('/');
|
|
636
|
+
const objectId = parts[2];
|
|
637
|
+
if (parts.length === 3) {
|
|
638
|
+
// /objects/{objectId} - full object add or remove
|
|
639
|
+
if (op.op === 'add') {
|
|
640
|
+
const entry = this._data.objects[objectId];
|
|
641
|
+
if (entry) {
|
|
642
|
+
this.emit('objectCreated', { objectId, object: entry.data, source });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
else if (op.op === 'remove') {
|
|
646
|
+
this.emit('objectDeleted', { objectId, source });
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
else if (parts[3] === 'data') {
|
|
650
|
+
// /objects/{objectId}/data/... - data field update
|
|
651
|
+
if (!updatedObjects.has(objectId)) {
|
|
652
|
+
const entry = this._data.objects[objectId];
|
|
653
|
+
if (entry) {
|
|
654
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source });
|
|
655
|
+
updatedObjects.add(objectId);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
else if (parts[3] === 'links') {
|
|
660
|
+
// /objects/{objectId}/links/{type}/{targetId}
|
|
661
|
+
if (parts.length >= 6) {
|
|
662
|
+
const edgeType = parts[4];
|
|
663
|
+
const targetId = parts[5];
|
|
664
|
+
if (op.op === 'add') {
|
|
665
|
+
const linkData = this._data.objects[objectId]?.links[edgeType]?.[targetId] ?? {};
|
|
666
|
+
this.emit('edgeCreated', { sourceId: objectId, targetId, edgeType, linkData, source });
|
|
667
|
+
}
|
|
668
|
+
else if (op.op === 'remove') {
|
|
669
|
+
this.emit('edgeDeleted', { sourceId: objectId, targetId, edgeType, source });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
else if (parts.length === 5 && op.op === 'add') {
|
|
673
|
+
// /objects/{objectId}/links/{type} - new link type object added
|
|
674
|
+
const edgeType = parts[4];
|
|
675
|
+
const targets = this._data.objects[objectId]?.links[edgeType] ?? {};
|
|
676
|
+
for (const [targetId, linkData] of Object.entries(targets)) {
|
|
677
|
+
this.emit('edgeCreated', { sourceId: objectId, targetId, edgeType, linkData: linkData, source });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else if (parts[3] === 'meta') {
|
|
682
|
+
// /objects/{objectId}/meta/... - object meta update
|
|
683
|
+
// Could emit an objectMeta event if needed, but for now treat as object update
|
|
684
|
+
if (!updatedObjects.has(objectId)) {
|
|
685
|
+
const entry = this._data.objects[objectId];
|
|
686
|
+
if (entry) {
|
|
687
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source });
|
|
688
|
+
updatedObjects.add(objectId);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Graph metadata
|
|
694
|
+
else if (path === '/meta' || path.startsWith('/meta/')) {
|
|
695
|
+
this.emit('metaUpdated', { meta: this._data.meta, source });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
531
698
|
}
|
|
532
699
|
/**
|
|
533
700
|
* Handle a full graph reload from server.
|
|
@@ -547,32 +714,6 @@ export class RoolGraph extends EventEmitter {
|
|
|
547
714
|
// ===========================================================================
|
|
548
715
|
// Private Methods
|
|
549
716
|
// ===========================================================================
|
|
550
|
-
sendPatch(patch) {
|
|
551
|
-
this.pendingPatch.push(...patch);
|
|
552
|
-
if (!this.patchScheduled) {
|
|
553
|
-
this.patchScheduled = true;
|
|
554
|
-
// Chain onto patchSettled so callers can await all pending patches
|
|
555
|
-
this.patchSettled = this.patchSettled.then(() => new Promise((resolve) => {
|
|
556
|
-
queueMicrotask(() => {
|
|
557
|
-
const batch = this.pendingPatch;
|
|
558
|
-
this.pendingPatch = [];
|
|
559
|
-
this.patchScheduled = false;
|
|
560
|
-
if (batch.length > 0) {
|
|
561
|
-
this.graphqlClient
|
|
562
|
-
.patch(this._id, batch)
|
|
563
|
-
.catch((error) => {
|
|
564
|
-
console.error('[Graph] Failed to send patch:', error);
|
|
565
|
-
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
566
|
-
})
|
|
567
|
-
.finally(resolve);
|
|
568
|
-
}
|
|
569
|
-
else {
|
|
570
|
-
resolve();
|
|
571
|
-
}
|
|
572
|
-
});
|
|
573
|
-
}));
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
717
|
async resyncFromServer(originalError) {
|
|
577
718
|
console.warn('[Graph] Resyncing from server after sync failure');
|
|
578
719
|
try {
|