@rool-dev/client 0.3.0 → 0.3.1-dev.34258b6

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/link 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/link operations
22
22
  * - Built-in undo/redo with checkpoints
23
23
  * - Metadata management
24
24
  * - Event emission for state changes
@@ -36,9 +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
39
  // Subscription state
43
40
  _isSubscribed = false;
44
41
  constructor(config) {
@@ -113,7 +110,6 @@ export class RoolGraph extends EventEmitter {
113
110
  this.unsubscribe();
114
111
  this.undoStack = [];
115
112
  this.redoStack = [];
116
- this.pendingPatch = [];
117
113
  this.removeAllListeners();
118
114
  }
119
115
  // ===========================================================================
@@ -169,7 +165,7 @@ export class RoolGraph extends EventEmitter {
169
165
  // Sync to server - resync on failure
170
166
  try {
171
167
  await this.graphqlClient.setGraph(this._id, this._data);
172
- this.emit('reset', { source: 'local' });
168
+ this.emit('reset', { source: 'local_user' });
173
169
  }
174
170
  catch (error) {
175
171
  console.error('[Graph] Failed to sync undo to server:', error);
@@ -197,7 +193,7 @@ export class RoolGraph extends EventEmitter {
197
193
  // Sync to server - resync on failure
198
194
  try {
199
195
  await this.graphqlClient.setGraph(this._id, this._data);
200
- this.emit('reset', { source: 'local' });
196
+ this.emit('reset', { source: 'local_user' });
201
197
  }
202
198
  catch (error) {
203
199
  console.error('[Graph] Failed to sync redo to server:', error);
@@ -214,198 +210,322 @@ export class RoolGraph extends EventEmitter {
214
210
  this.redoStack = [];
215
211
  }
216
212
  // ===========================================================================
217
- // Node Operations
213
+ // Object Operations
218
214
  // ===========================================================================
219
215
  /**
220
- * Get a node by ID.
221
- * @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
222
219
  */
223
- getNode(nodeId) {
224
- const node = this._data.nodes[nodeId];
225
- if (!node) {
226
- 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`);
227
224
  }
228
- return node;
225
+ return entry.data;
229
226
  }
230
227
  /**
231
- * Get a node by ID, or undefined if not found.
228
+ * Get an object's data by ID, or undefined if not found.
232
229
  */
233
- getNodeOrUndefined(nodeId) {
234
- return this._data.nodes[nodeId];
230
+ getObjectOrUndefined(objectId) {
231
+ return this._data.objects[objectId]?.data;
235
232
  }
236
233
  /**
237
- * Get all nodes of a specific type.
234
+ * Get an object's metadata (position, UI state, etc).
238
235
  */
239
- getNodesByType(type) {
240
- 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;
241
242
  }
242
243
  /**
243
- * Get all node IDs.
244
+ * Get all objects of a specific type.
244
245
  */
245
- getNodeIds() {
246
- 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);
247
250
  }
248
251
  /**
249
- * Add a new node.
252
+ * Get all object IDs.
250
253
  */
251
- addNode(nodeId, node) {
252
- this._data.nodes[nodeId] = node;
253
- const ops = [{ op: 'add', path: `/nodes/${nodeId}`, value: node }];
254
- this.sendPatch(ops);
255
- this.emit('patch', { ops, source: 'local' });
254
+ getObjectIds() {
255
+ return Object.keys(this._data.objects);
256
256
  }
257
257
  /**
258
- * Update an existing node.
259
- * @param updates - Partial node data to merge
260
- * @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
261
264
  */
262
- updateNode(nodeId, updates) {
263
- const node = this._data.nodes[nodeId];
264
- if (!node) {
265
- 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 };
266
281
  }
267
- // Merge updates locally
268
- Object.assign(node, updates);
269
- // Optimization: If only _meta is being updated, send a smaller patch
270
- const updateKeys = Object.keys(updates);
271
- let ops;
272
- if (updateKeys.length === 1 && updateKeys[0] === '_meta' && updates._meta) {
273
- // "add" is an upsert operation, so it works regardless of whether the _meta exists or not
274
- 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;
275
286
  }
276
- else {
277
- 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;
278
324
  }
279
- this.sendPatch(ops);
280
- this.emit('patch', { ops, source: 'local' });
281
325
  }
282
326
  /**
283
- * Delete nodes by IDs.
284
- * 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).
285
330
  */
286
- deleteNodes(nodeIds) {
287
- const patch = [];
288
- const nodeIdSet = new Set(nodeIds);
289
- // Remove edges connected to any deleted node
290
- for (const [edgeId, edge] of Object.entries(this._data.edges)) {
291
- const hasDeletedSource = edge.sources.some(s => nodeIdSet.has(s));
292
- const hasDeletedTarget = edge.targets.some(t => nodeIdSet.has(t));
293
- if (hasDeletedSource || hasDeletedTarget) {
294
- delete this._data.edges[edgeId];
295
- patch.push({ op: 'remove', path: `/edges/${edgeId}` });
331
+ async deleteObjects(objectIds) {
332
+ if (objectIds.length === 0)
333
+ return;
334
+ const deletedObjectIds = [];
335
+ // Collect links that will be orphaned (for events)
336
+ const deletedLinks = [];
337
+ for (const objectId of objectIds) {
338
+ const entry = this._data.objects[objectId];
339
+ if (entry) {
340
+ // Collect outbound links for deletion events
341
+ for (const [linkType, targets] of Object.entries(entry.links)) {
342
+ for (const targetId of Object.keys(targets)) {
343
+ deletedLinks.push({ sourceId: objectId, targetId, linkType });
344
+ }
345
+ }
296
346
  }
297
347
  }
298
- // Remove nodes
299
- for (const nodeId of nodeIds) {
300
- if (this._data.nodes[nodeId]) {
301
- delete this._data.nodes[nodeId];
302
- 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);
303
353
  }
304
354
  }
305
- if (patch.length > 0) {
306
- this.sendPatch(patch);
307
- this.emit('patch', { ops: patch, source: 'local' });
355
+ // Emit semantic events
356
+ for (const link of deletedLinks) {
357
+ this.emit('unlinked', { ...link, 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;
308
370
  }
309
371
  }
310
372
  // ===========================================================================
311
- // Edge Operations
373
+ // Link Operations
312
374
  // ===========================================================================
313
375
  /**
314
- * Create an edge between nodes.
315
- * @returns The generated edge ID
376
+ * Create a link between objects.
377
+ * Links are stored on the source object.
316
378
  */
317
- linkNodes(sourceId, targetId, edgeType) {
318
- const edgeId = generateEntityId();
319
- const edge = {
320
- sources: [sourceId],
321
- targets: [targetId],
322
- type: edgeType,
323
- _meta: {},
324
- };
325
- this._data.edges[edgeId] = edge;
326
- const ops = [{ op: 'add', path: `/edges/${edgeId}`, value: edge }];
327
- this.sendPatch(ops);
328
- this.emit('patch', { ops, source: 'local' });
329
- return edgeId;
330
- }
331
- /**
332
- * Remove edges between two nodes.
333
- * @returns true if any edges were removed
334
- */
335
- unlinkNodes(sourceId, targetId) {
336
- const patch = [];
337
- const removedEdgeIds = [];
338
- for (const [edgeId, edge] of Object.entries(this._data.edges)) {
339
- if (edge.sources.includes(sourceId) && edge.targets.includes(targetId)) {
340
- delete this._data.edges[edgeId];
341
- patch.push({ op: 'remove', path: `/edges/${edgeId}` });
342
- removedEdgeIds.push(edgeId);
343
- }
379
+ async link(sourceId, targetId, linkType) {
380
+ const entry = this._data.objects[sourceId];
381
+ if (!entry) {
382
+ throw new Error(`Source object ${sourceId} not found`);
344
383
  }
345
- if (patch.length > 0) {
346
- this.sendPatch(patch);
347
- this.emit('patch', { ops: patch, source: 'local' });
348
- return true;
384
+ // Update local state immediately
385
+ if (!entry.links[linkType]) {
386
+ entry.links[linkType] = {};
387
+ }
388
+ entry.links[linkType][targetId] = {};
389
+ const linkData = entry.links[linkType][targetId];
390
+ this.emit('linked', { sourceId, targetId, linkType, linkData, source: 'local_user' });
391
+ // Await server call
392
+ try {
393
+ await this.graphqlClient.link(this.id, sourceId, targetId, linkType);
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;
349
399
  }
350
- return false;
351
- }
352
- /**
353
- * Get parent node IDs (nodes that have edges pointing TO this node).
354
- * @param edgeType - Optional filter by edge type
355
- */
356
- getParents(nodeId, edgeType) {
357
- return Object.values(this._data.edges)
358
- .filter(edge => edge.targets.includes(nodeId) &&
359
- (!edgeType || edge.type === edgeType))
360
- .flatMap(edge => edge.sources);
361
400
  }
362
401
  /**
363
- * Get child node IDs (nodes that this node has edges pointing TO).
364
- * @param edgeType - Optional filter by edge type
402
+ * Remove a link between two objects.
403
+ * @param linkType - Optional: if provided, only removes that type; otherwise removes all links between the objects
404
+ * @returns true if any links were removed
365
405
  */
366
- getChildren(nodeId, edgeType) {
367
- return Object.values(this._data.edges)
368
- .filter(edge => edge.sources.includes(nodeId) &&
369
- (!edgeType || edge.type === edgeType))
370
- .flatMap(edge => edge.targets);
406
+ async unlink(sourceId, targetId, linkType) {
407
+ const entry = this._data.objects[sourceId];
408
+ if (!entry) {
409
+ throw new Error(`Source object ${sourceId} not found`);
410
+ }
411
+ const deletedLinks = [];
412
+ // Update local state
413
+ if (linkType) {
414
+ // Remove specific link type
415
+ if (entry.links[linkType]?.[targetId] !== undefined) {
416
+ delete entry.links[linkType][targetId];
417
+ deletedLinks.push({ linkType });
418
+ }
419
+ }
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
+ deletedLinks.push({ linkType: type });
426
+ }
427
+ }
428
+ }
429
+ // Emit semantic events
430
+ for (const link of deletedLinks) {
431
+ this.emit('unlinked', { sourceId, targetId, linkType: link.linkType, source: 'local_user' });
432
+ }
433
+ // Await server call
434
+ try {
435
+ await this.graphqlClient.unlink(this.id, sourceId, targetId);
436
+ }
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 deletedLinks.length > 0;
371
443
  }
372
444
  /**
373
- * Get an edge by ID.
445
+ * Get parent object IDs (objects that have links pointing TO this object).
446
+ * @param linkType - Optional filter by link type
374
447
  */
375
- getEdge(edgeId) {
376
- return this._data.edges[edgeId];
448
+ getParents(objectId, linkType) {
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 ((!linkType || type === linkType) && objectId in targets) {
453
+ parents.push(entryId);
454
+ break; // Found a link, move to next object
455
+ }
456
+ }
457
+ }
458
+ return parents;
459
+ }
460
+ /**
461
+ * Get child object IDs (objects that this object has links pointing TO).
462
+ * Filters out orphan targets (targets that don't exist).
463
+ * @param linkType - Optional filter by link type
464
+ */
465
+ getChildren(objectId, linkType) {
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 (!linkType || type === linkType) {
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;
377
481
  }
378
482
  /**
379
- * Get all edge IDs.
483
+ * Get all child object IDs including orphans (targets that may not exist).
484
+ * @param linkType - Optional filter by link type
380
485
  */
381
- getEdgeIds() {
382
- return Object.keys(this._data.edges);
486
+ getChildrenIncludingOrphans(objectId, linkType) {
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 (!linkType || type === linkType) {
493
+ children.push(...Object.keys(targets));
494
+ }
495
+ }
496
+ return children;
383
497
  }
384
498
  // ===========================================================================
385
499
  // Metadata Operations
386
500
  // ===========================================================================
387
501
  /**
388
- * Set a metadata value.
389
- * 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.
390
504
  */
391
505
  setMetadata(key, value) {
392
- if (!this._data._meta) {
393
- this._data._meta = {};
506
+ if (!this._data.meta) {
507
+ this._data.meta = {};
394
508
  }
395
- this._data._meta[key] = value;
396
- this.sendPatch([{ op: 'replace', 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
+ });
397
517
  }
398
518
  /**
399
- * Get a metadata value.
519
+ * Get a graph-level metadata value.
400
520
  */
401
521
  getMetadata(key) {
402
- return this._data._meta?.[key];
522
+ return this._data.meta?.[key];
403
523
  }
404
524
  /**
405
- * Get all metadata.
525
+ * Get all graph-level metadata.
406
526
  */
407
527
  getAllMetadata() {
408
- return this._data._meta ?? {};
528
+ return this._data.meta ?? {};
409
529
  }
410
530
  // ===========================================================================
411
531
  // AI Operations
@@ -416,18 +536,6 @@ export class RoolGraph extends EventEmitter {
416
536
  async prompt(prompt, options) {
417
537
  return this.graphqlClient.promptGraph(this._id, prompt, options);
418
538
  }
419
- /**
420
- * Generate an AI image.
421
- */
422
- async generateImage(prompt, aspectRatio) {
423
- return this.graphqlClient.generateImage(this._id, prompt, aspectRatio);
424
- }
425
- /**
426
- * Edit an existing image using AI.
427
- */
428
- async editImage(prompt, url) {
429
- return this.graphqlClient.editImage(this._id, prompt, url);
430
- }
431
539
  // ===========================================================================
432
540
  // Collaboration
433
541
  // ===========================================================================
@@ -492,21 +600,6 @@ export class RoolGraph extends EventEmitter {
492
600
  getData() {
493
601
  return this._data;
494
602
  }
495
- /**
496
- * Apply a raw JSON patch.
497
- * Use sparingly - prefer semantic operations.
498
- */
499
- patch(operations) {
500
- try {
501
- this._data = immutableJSONPatch(this._data, operations);
502
- }
503
- catch (error) {
504
- console.error('[Graph] Failed to apply patch:', error);
505
- return;
506
- }
507
- this.sendPatch(operations);
508
- this.emit('patch', { ops: operations, source: 'local' });
509
- }
510
603
  // ===========================================================================
511
604
  // Event Handlers (called by RoolClient for routing)
512
605
  // ===========================================================================
@@ -524,8 +617,84 @@ export class RoolGraph extends EventEmitter {
524
617
  this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
525
618
  return;
526
619
  }
527
- const emitterSource = source === 'agent' ? 'remote_agent' : 'remote_user';
528
- 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 linkType = parts[4];
663
+ const targetId = parts[5];
664
+ if (op.op === 'add') {
665
+ const linkData = this._data.objects[objectId]?.links[linkType]?.[targetId] ?? {};
666
+ this.emit('linked', { sourceId: objectId, targetId, linkType, linkData, source });
667
+ }
668
+ else if (op.op === 'remove') {
669
+ this.emit('unlinked', { sourceId: objectId, targetId, linkType, source });
670
+ }
671
+ }
672
+ else if (parts.length === 5 && op.op === 'add') {
673
+ // /objects/{objectId}/links/{type} - new link type object added
674
+ const linkType = parts[4];
675
+ const targets = this._data.objects[objectId]?.links[linkType] ?? {};
676
+ for (const [targetId, linkData] of Object.entries(targets)) {
677
+ this.emit('linked', { sourceId: objectId, targetId, linkType, 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
+ }
529
698
  }
530
699
  /**
531
700
  * Handle a full graph reload from server.
@@ -545,23 +714,6 @@ export class RoolGraph extends EventEmitter {
545
714
  // ===========================================================================
546
715
  // Private Methods
547
716
  // ===========================================================================
548
- sendPatch(patch) {
549
- this.pendingPatch.push(...patch);
550
- if (!this.patchScheduled) {
551
- this.patchScheduled = true;
552
- queueMicrotask(() => {
553
- const batch = this.pendingPatch;
554
- this.pendingPatch = [];
555
- this.patchScheduled = false;
556
- if (batch.length > 0) {
557
- this.graphqlClient.patch(this._id, batch).catch((error) => {
558
- console.error('[Graph] Failed to send patch:', error);
559
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
560
- });
561
- }
562
- });
563
- }
564
- }
565
717
  async resyncFromServer(originalError) {
566
718
  console.warn('[Graph] Resyncing from server after sync failure');
567
719
  try {