@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/dist/graph.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // =============================================================================
2
2
  // Graph
3
- // First-class Graph object with node/edge operations, undo/redo, and events
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 node/edge operations
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: 'local' });
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: 'local' });
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
- // Node Operations
213
+ // Object Operations
225
214
  // ===========================================================================
226
215
  /**
227
- * Get a node by ID.
228
- * @throws Error if node not found
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
- getNode(nodeId) {
231
- const node = this._data.nodes[nodeId];
232
- if (!node) {
233
- throw new Error(`Node ${nodeId} not found`);
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 node;
225
+ return entry.data;
236
226
  }
237
227
  /**
238
- * Get a node by ID, or undefined if not found.
228
+ * Get an object's data by ID, or undefined if not found.
239
229
  */
240
- getNodeOrUndefined(nodeId) {
241
- return this._data.nodes[nodeId];
230
+ getObjectOrUndefined(objectId) {
231
+ return this._data.objects[objectId]?.data;
242
232
  }
243
233
  /**
244
- * Get all nodes of a specific type.
234
+ * Get an object's metadata (position, UI state, etc).
245
235
  */
246
- getNodesByType(type) {
247
- return Object.values(this._data.nodes).filter(node => node.type === type);
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 node IDs.
244
+ * Get all objects of a specific type.
251
245
  */
252
- getNodeIds() {
253
- return Object.keys(this._data.nodes);
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
- * Add a new node.
252
+ * Get all object IDs.
257
253
  */
258
- addNode(nodeId, node) {
259
- this._data.nodes[nodeId] = node;
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
- * Update an existing node.
266
- * @param updates - Partial node data to merge
267
- * @param persist - If false, update local state only (for drag/animation)
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
- updateNode(nodeId, updates) {
270
- const node = this._data.nodes[nodeId];
271
- if (!node) {
272
- throw new Error(`Node ${nodeId} not found for update`);
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
- // Merge updates locally
275
- Object.assign(node, updates);
276
- // Optimization: If only _meta is being updated, send a smaller patch
277
- const updateKeys = Object.keys(updates);
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
- else {
284
- ops = [{ op: 'replace', path: `/nodes/${nodeId}`, value: node }];
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 nodes by IDs.
291
- * Also removes any edges connected to the deleted nodes.
327
+ * Delete objects by IDs.
328
+ * Outbound links are automatically deleted with the object.
329
+ * Inbound links become orphans (tolerated).
292
330
  */
293
- async deleteNodes(nodeIds) {
294
- if (nodeIds.length === 0)
331
+ async deleteObjects(objectIds) {
332
+ if (objectIds.length === 0)
295
333
  return;
296
- const patch = [];
297
- const nodeIdSet = new Set(nodeIds);
298
- // Remove edges connected to any deleted node (local state)
299
- for (const [edgeId, edge] of Object.entries(this._data.edges)) {
300
- const hasDeletedSource = edge.sources.some(s => nodeIdSet.has(s));
301
- const hasDeletedTarget = edge.targets.some(t => nodeIdSet.has(t));
302
- if (hasDeletedSource || hasDeletedTarget) {
303
- delete this._data.edges[edgeId];
304
- patch.push({ op: 'remove', path: `/edges/${edgeId}` });
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 nodes (local state)
308
- for (const nodeId of nodeIds) {
309
- if (this._data.nodes[nodeId]) {
310
- delete this._data.nodes[nodeId];
311
- patch.push({ op: 'remove', path: `/nodes/${nodeId}` });
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
- if (patch.length > 0) {
315
- this.emit('patch', { ops: patch, source: 'local' });
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 an edge between nodes.
325
- * @returns The generated edge ID
376
+ * Create a link between objects.
377
+ * Links are stored on the source object.
326
378
  */
327
- async createEdge(sourceId, targetId, edgeType) {
328
- const edgeId = generateEntityId();
329
- const edge = {
330
- sources: [sourceId],
331
- targets: [targetId],
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
- this._data.edges[edgeId] = edge;
337
- const ops = [{ op: 'add', path: `/edges/${edgeId}`, value: edge }];
338
- this.emit('patch', { ops, source: 'local' });
339
- // Send to server via mutation
340
- await this.graphqlClient.createEdge(this.id, edgeId, sourceId, targetId, edgeType);
341
- return edgeId;
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 edges between two nodes.
345
- * @returns true if any edges were removed
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 deleteEdge(sourceId, targetId) {
348
- const patch = [];
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
- for (const [edgeId, edge] of Object.entries(this._data.edges)) {
351
- if (edge.sources.includes(sourceId) && edge.targets.includes(targetId)) {
352
- delete this._data.edges[edgeId];
353
- patch.push({ op: 'remove', path: `/edges/${edgeId}` });
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
- if (patch.length > 0) {
357
- this.emit('patch', { ops: patch, source: 'local' });
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
- // Send to server via mutation
360
- await this.graphqlClient.deleteEdge(this.id, sourceId, targetId);
361
- return patch.length > 0;
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 node IDs (nodes that have edges pointing TO this node).
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(nodeId, edgeType) {
368
- return Object.values(this._data.edges)
369
- .filter(edge => edge.targets.includes(nodeId) &&
370
- (!edgeType || edge.type === edgeType))
371
- .flatMap(edge => edge.sources);
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 node IDs (nodes that this node has edges pointing TO).
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(nodeId, edgeType) {
378
- return Object.values(this._data.edges)
379
- .filter(edge => edge.sources.includes(nodeId) &&
380
- (!edgeType || edge.type === edgeType))
381
- .flatMap(edge => edge.targets);
382
- }
383
- /**
384
- * Get an edge by ID.
385
- */
386
- getEdge(edgeId) {
387
- return this._data.edges[edgeId];
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 edge IDs.
483
+ * Get all child object IDs including orphans (targets that may not exist).
484
+ * @param edgeType - Optional filter by edge type
391
485
  */
392
- getEdgeIds() {
393
- return Object.keys(this._data.edges);
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 _meta and hidden from AI operations.
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._meta) {
404
- this._data._meta = {};
506
+ if (!this._data.meta) {
507
+ this._data.meta = {};
405
508
  }
406
- this._data._meta[key] = value;
407
- // "add" is an upsert operation, so it works regardless of whether /_meta exists
408
- this.sendPatch([{ op: 'add', path: '/_meta', value: this._data._meta }]);
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._meta?.[key];
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._meta ?? {};
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
- const emitterSource = source === 'agent' ? 'remote_agent' : 'remote_user';
530
- this.emit('patch', { ops: patch, source: emitterSource });
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 {