@rool-dev/sdk 0.2.0-dev.29da71e → 0.2.0-dev.bf65f1e

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/space.js CHANGED
@@ -1,4 +1,3 @@
1
- import { immutableJSONPatch } from 'immutable-json-patch';
2
1
  import { EventEmitter } from './event-emitter.js';
3
2
  import { SpaceSubscriptionManager } from './subscription.js';
4
3
  // 6-character alphanumeric ID (62^6 = 56.8 billion possible values)
@@ -10,9 +9,15 @@ export function generateEntityId() {
10
9
  }
11
10
  return result;
12
11
  }
12
+ // Default timeout for waiting on SSE object events (30 seconds)
13
+ const OBJECT_COLLECT_TIMEOUT = 30000;
13
14
  /**
14
15
  * First-class Space object.
15
16
  *
17
+ * Objects are fetched on demand from the server; only schema, metadata,
18
+ * and conversations are cached locally. Object changes arrive via SSE
19
+ * semantic events and are emitted as SDK events.
20
+ *
16
21
  * Features:
17
22
  * - High-level object operations
18
23
  * - Built-in undo/redo with checkpoints
@@ -27,7 +32,6 @@ export class RoolSpace extends EventEmitter {
27
32
  _linkAccess;
28
33
  _userId;
29
34
  _conversationId;
30
- _data;
31
35
  _closed = false;
32
36
  graphqlClient;
33
37
  mediaClient;
@@ -35,6 +39,18 @@ export class RoolSpace extends EventEmitter {
35
39
  onCloseCallback;
36
40
  _subscriptionReady;
37
41
  logger;
42
+ // Local cache for bounded data (schema, metadata, conversations, object IDs)
43
+ _meta;
44
+ _schema;
45
+ _conversations;
46
+ _objectIds;
47
+ // Object collection: tracks pending local mutations for dedup
48
+ // Maps objectId → optimistic object data (for create/update) or null (for delete)
49
+ _pendingMutations = new Map();
50
+ // Resolvers waiting for object data from SSE events
51
+ _objectResolvers = new Map();
52
+ // Buffer for object data that arrived before a collector was registered
53
+ _objectBuffer = new Map();
38
54
  constructor(config) {
39
55
  super();
40
56
  this._id = config.id;
@@ -44,11 +60,15 @@ export class RoolSpace extends EventEmitter {
44
60
  this._userId = config.userId;
45
61
  this._emitterLogger = config.logger;
46
62
  this._conversationId = config.conversationId ?? generateEntityId();
47
- this._data = config.initialData;
48
63
  this.graphqlClient = config.graphqlClient;
49
64
  this.mediaClient = config.mediaClient;
50
65
  this.logger = config.logger;
51
66
  this.onCloseCallback = config.onClose;
67
+ // Initialize local cache from server data
68
+ this._meta = config.initialData.meta ?? {};
69
+ this._schema = config.initialData.schema ?? {};
70
+ this._conversations = config.initialData.conversations ?? {};
71
+ this._objectIds = config.initialData.objectIds ?? [];
52
72
  // Create space-level subscription
53
73
  this.subscriptionManager = new SpaceSubscriptionManager({
54
74
  graphqlUrl: config.graphqlUrl,
@@ -126,20 +146,20 @@ export class RoolSpace extends EventEmitter {
126
146
  * Returns the interactions array.
127
147
  */
128
148
  getInteractions() {
129
- return this._data.conversations?.[this._conversationId]?.interactions ?? [];
149
+ return this._conversations[this._conversationId]?.interactions ?? [];
130
150
  }
131
151
  /**
132
152
  * Get interactions for a specific conversation ID.
133
153
  * Useful for viewing other conversations in the space.
134
154
  */
135
155
  getInteractionsById(conversationId) {
136
- return this._data.conversations?.[conversationId]?.interactions ?? [];
156
+ return this._conversations[conversationId]?.interactions ?? [];
137
157
  }
138
158
  /**
139
159
  * Get all conversation IDs that have conversations in this space.
140
160
  */
141
161
  getConversationIds() {
142
- return Object.keys(this._data.conversations ?? {});
162
+ return Object.keys(this._conversations);
143
163
  }
144
164
  // ===========================================================================
145
165
  // Space Lifecycle
@@ -166,6 +186,10 @@ export class RoolSpace extends EventEmitter {
166
186
  this._closed = true;
167
187
  this.subscriptionManager.destroy();
168
188
  this.onCloseCallback(this._id);
189
+ // Clean up pending object collectors
190
+ this._objectResolvers.clear();
191
+ this._objectBuffer.clear();
192
+ this._pendingMutations.clear();
169
193
  this.removeAllListeners();
170
194
  }
171
195
  // ===========================================================================
@@ -173,7 +197,6 @@ export class RoolSpace extends EventEmitter {
173
197
  // ===========================================================================
174
198
  /**
175
199
  * Create a checkpoint (seal current batch of changes).
176
- * Patches accumulate automatically - this seals them with a label.
177
200
  * @returns The checkpoint ID
178
201
  */
179
202
  async checkpoint(label = 'Change') {
@@ -202,7 +225,7 @@ export class RoolSpace extends EventEmitter {
202
225
  */
203
226
  async undo() {
204
227
  const result = await this.graphqlClient.undo(this._id, this._conversationId);
205
- // Server broadcasts space_patched if successful, which updates local state
228
+ // Server broadcasts space_changed, which triggers reset event
206
229
  return result.success;
207
230
  }
208
231
  /**
@@ -211,7 +234,7 @@ export class RoolSpace extends EventEmitter {
211
234
  */
212
235
  async redo() {
213
236
  const result = await this.graphqlClient.redo(this._id, this._conversationId);
214
- // Server broadcasts space_patched if successful, which updates local state
237
+ // Server broadcasts space_changed, which triggers reset event
215
238
  return result.success;
216
239
  }
217
240
  /**
@@ -225,24 +248,19 @@ export class RoolSpace extends EventEmitter {
225
248
  // ===========================================================================
226
249
  /**
227
250
  * Get an object's data by ID.
228
- * Returns just the data portion (RoolObject), not the full entry with meta/links.
251
+ * Fetches from the server on each call.
229
252
  */
230
253
  async getObject(objectId) {
231
- return this._data.objects[objectId]?.data;
254
+ return this.graphqlClient.getObject(this._id, objectId);
232
255
  }
233
256
  /**
234
257
  * Get an object's stat (audit information).
235
258
  * Returns modification timestamp and author, or undefined if object not found.
236
259
  */
237
- async stat(objectId) {
238
- const entry = this._data.objects[objectId];
239
- if (!entry)
240
- return undefined;
241
- return {
242
- modifiedAt: entry.modifiedAt,
243
- modifiedBy: entry.modifiedBy,
244
- modifiedByName: entry.modifiedByName,
245
- };
260
+ async stat(_objectId) {
261
+ // TODO: Requires a dedicated server endpoint for object audit info
262
+ this.logger.warn('[RoolSpace] stat() not yet supported in stateless mode');
263
+ return undefined;
246
264
  }
247
265
  /**
248
266
  * Find objects using structured filters and/or natural language.
@@ -258,44 +276,22 @@ export class RoolSpace extends EventEmitter {
258
276
  * @param options.order - Sort order by modifiedAt: `'asc'` or `'desc'` (default: `'desc'`). Only applies to structured filtering (no `prompt`).
259
277
  * @param options.ephemeral - If true, the query won't be recorded in conversation history.
260
278
  * @returns The matching objects and a descriptive message.
261
- *
262
- * @example
263
- * // Exact match (no AI, no credits)
264
- * const { objects } = await space.findObjects({ where: { type: 'article' } });
265
- *
266
- * @example
267
- * // Natural language (AI query)
268
- * const { objects, message } = await space.findObjects({
269
- * prompt: 'articles about space exploration'
270
- * });
271
- *
272
- * @example
273
- * // Combined — where narrows the data, prompt queries within it
274
- * const { objects } = await space.findObjects({
275
- * where: { type: 'article' },
276
- * prompt: 'that discuss climate solutions positively',
277
- * limit: 10
278
- * });
279
279
  */
280
280
  async findObjects(options) {
281
281
  return this.graphqlClient.findObjects(this._id, options, this._conversationId);
282
282
  }
283
283
  /**
284
- * Get all object IDs.
284
+ * Get all object IDs (sync, from local cache).
285
+ * The list is loaded on open and kept current via SSE events.
285
286
  * @param options.limit - Maximum number of IDs to return
286
287
  * @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
287
288
  */
288
289
  getObjectIds(options) {
289
- const order = options?.order ?? 'desc';
290
- let entries = Object.entries(this._data.objects);
291
- // Sort by modifiedAt
292
- entries.sort((a, b) => {
293
- const aTime = a[1].modifiedAt ?? 0;
294
- const bTime = b[1].modifiedAt ?? 0;
295
- return order === 'desc' ? bTime - aTime : aTime - bTime;
296
- });
297
- let ids = entries.map(([id]) => id);
298
- if (options?.limit) {
290
+ let ids = this._objectIds;
291
+ if (options?.order === 'asc') {
292
+ ids = [...ids].reverse();
293
+ }
294
+ if (options?.limit !== undefined) {
299
295
  ids = ids.slice(0, options.limit);
300
296
  }
301
297
  return ids;
@@ -314,30 +310,25 @@ export class RoolSpace extends EventEmitter {
314
310
  if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
315
311
  throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
316
312
  }
317
- // Fail if object already exists
318
- if (this._data.objects[objectId]) {
319
- throw new Error(`Object "${objectId}" already exists`);
320
- }
321
313
  const dataWithId = { ...data, id: objectId };
322
- // Build the entry for local state (optimistic - server will overwrite audit fields)
323
- const entry = {
324
- data: dataWithId,
325
- modifiedAt: Date.now(),
326
- modifiedBy: this._userId,
327
- modifiedByName: null,
328
- };
329
- // Update local state immediately (optimistic)
330
- this._data.objects[objectId] = entry;
331
- this.emit('objectCreated', { objectId, object: entry.data, source: 'local_user' });
332
- // Await server call (may trigger AI processing that updates local state via patches)
314
+ // Emit optimistic event and track for dedup
315
+ this._pendingMutations.set(objectId, dataWithId);
316
+ this.emit('objectCreated', { objectId, object: dataWithId, source: 'local_user' });
333
317
  try {
334
- const message = await this.graphqlClient.createObject(this.id, dataWithId, this._conversationId, ephemeral);
335
- // Return current state (may have been updated by AI patches)
336
- return { object: this._data.objects[objectId].data, message };
318
+ // Await mutation server processes AI placeholders before responding.
319
+ // SSE events arrive during the await and are buffered via _deliverObject.
320
+ const { message } = await this.graphqlClient.createObject(this.id, dataWithId, this._conversationId, ephemeral);
321
+ // Collect resolved object from buffer (or wait if not yet arrived)
322
+ const object = await this._collectObject(objectId);
323
+ return { object, message };
337
324
  }
338
325
  catch (error) {
339
326
  this.logger.error('[RoolSpace] Failed to create object:', error);
340
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
327
+ this._pendingMutations.delete(objectId);
328
+ this._cancelCollector(objectId);
329
+ // Emit reset so UI can recover from the optimistic event
330
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
331
+ this.emit('reset', { source: 'system' });
341
332
  throw error;
342
333
  }
343
334
  }
@@ -350,10 +341,6 @@ export class RoolSpace extends EventEmitter {
350
341
  * @returns The updated object (with AI-filled content) and message
351
342
  */
352
343
  async updateObject(objectId, options) {
353
- const entry = this._data.objects[objectId];
354
- if (!entry) {
355
- throw new Error(`Object ${objectId} not found for update`);
356
- }
357
344
  const { data, ephemeral } = options;
358
345
  // id is immutable after creation (but null/undefined means delete attempt, which we also reject)
359
346
  if (data?.id !== undefined && data.id !== null) {
@@ -371,30 +358,24 @@ export class RoolSpace extends EventEmitter {
371
358
  serverData[key] = value === undefined ? null : value;
372
359
  }
373
360
  }
374
- // Build local updates (apply deletions and updates)
375
- if (data) {
376
- for (const [key, value] of Object.entries(data)) {
377
- if (value === null || value === undefined) {
378
- delete entry.data[key];
379
- }
380
- else {
381
- entry.data[key] = value;
382
- }
383
- }
384
- }
385
- // Emit semantic event with updated object
361
+ // Emit optimistic event if we have data changes
386
362
  if (data) {
387
- this.emit('objectUpdated', { objectId, object: entry.data, source: 'local_user' });
363
+ // Build optimistic object (best effort we may not have the current state)
364
+ const optimistic = { id: objectId, ...data };
365
+ this._pendingMutations.set(objectId, optimistic);
366
+ this.emit('objectUpdated', { objectId, object: optimistic, source: 'local_user' });
388
367
  }
389
- // Await server call (may trigger AI processing that updates local state via patches)
390
368
  try {
391
- const message = await this.graphqlClient.updateObject(this.id, objectId, this._conversationId, serverData, options.prompt, ephemeral);
392
- // Return current state (may have been updated by AI patches)
393
- return { object: this._data.objects[objectId].data, message };
369
+ const { message } = await this.graphqlClient.updateObject(this.id, objectId, this._conversationId, serverData, options.prompt, ephemeral);
370
+ const object = await this._collectObject(objectId);
371
+ return { object, message };
394
372
  }
395
373
  catch (error) {
396
374
  this.logger.error('[RoolSpace] Failed to update object:', error);
397
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
375
+ this._pendingMutations.delete(objectId);
376
+ this._cancelCollector(objectId);
377
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
378
+ this.emit('reset', { source: 'system' });
398
379
  throw error;
399
380
  }
400
381
  }
@@ -405,25 +386,95 @@ export class RoolSpace extends EventEmitter {
405
386
  async deleteObjects(objectIds) {
406
387
  if (objectIds.length === 0)
407
388
  return;
408
- const deletedObjectIds = [];
409
- // Remove objects (local state)
389
+ // Track for dedup and emit optimistic events
410
390
  for (const objectId of objectIds) {
411
- if (this._data.objects[objectId]) {
412
- delete this._data.objects[objectId];
413
- deletedObjectIds.push(objectId);
414
- }
415
- }
416
- // Emit semantic events
417
- for (const objectId of deletedObjectIds) {
391
+ this._pendingMutations.set(objectId, null);
418
392
  this.emit('objectDeleted', { objectId, source: 'local_user' });
419
393
  }
420
- // Await server call
421
394
  try {
422
395
  await this.graphqlClient.deleteObjects(this.id, objectIds, this._conversationId);
423
396
  }
424
397
  catch (error) {
425
398
  this.logger.error('[RoolSpace] Failed to delete objects:', error);
426
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
399
+ for (const objectId of objectIds) {
400
+ this._pendingMutations.delete(objectId);
401
+ }
402
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
403
+ this.emit('reset', { source: 'system' });
404
+ throw error;
405
+ }
406
+ }
407
+ // ===========================================================================
408
+ // Collection Schema Operations
409
+ // ===========================================================================
410
+ /**
411
+ * Get the current schema for this space.
412
+ * Returns a map of collection names to their definitions.
413
+ */
414
+ getSchema() {
415
+ return this._schema;
416
+ }
417
+ /**
418
+ * Create a new collection schema.
419
+ * @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
420
+ * @param props - Property definitions for the collection
421
+ * @returns The created CollectionDef
422
+ */
423
+ async createCollection(name, props) {
424
+ if (this._schema[name]) {
425
+ throw new Error(`Collection "${name}" already exists`);
426
+ }
427
+ // Optimistic local update
428
+ const optimisticDef = { props: props.map(p => ({ name: p.name, type: p.type })) };
429
+ this._schema[name] = optimisticDef;
430
+ try {
431
+ return await this.graphqlClient.createCollection(this._id, name, props, this._conversationId);
432
+ }
433
+ catch (error) {
434
+ this.logger.error('[RoolSpace] Failed to create collection:', error);
435
+ delete this._schema[name];
436
+ throw error;
437
+ }
438
+ }
439
+ /**
440
+ * Alter an existing collection schema, replacing its property definitions.
441
+ * @param name - Name of the collection to alter
442
+ * @param props - New property definitions (replaces all existing props)
443
+ * @returns The updated CollectionDef
444
+ */
445
+ async alterCollection(name, props) {
446
+ if (!this._schema[name]) {
447
+ throw new Error(`Collection "${name}" not found`);
448
+ }
449
+ const previous = this._schema[name];
450
+ // Optimistic local update
451
+ this._schema[name] = { props: props.map(p => ({ name: p.name, type: p.type })) };
452
+ try {
453
+ return await this.graphqlClient.alterCollection(this._id, name, props, this._conversationId);
454
+ }
455
+ catch (error) {
456
+ this.logger.error('[RoolSpace] Failed to alter collection:', error);
457
+ this._schema[name] = previous;
458
+ throw error;
459
+ }
460
+ }
461
+ /**
462
+ * Drop a collection schema.
463
+ * @param name - Name of the collection to drop
464
+ */
465
+ async dropCollection(name) {
466
+ if (!this._schema[name]) {
467
+ throw new Error(`Collection "${name}" not found`);
468
+ }
469
+ const previous = this._schema[name];
470
+ // Optimistic local update
471
+ delete this._schema[name];
472
+ try {
473
+ await this.graphqlClient.dropCollection(this._id, name, this._conversationId);
474
+ }
475
+ catch (error) {
476
+ this.logger.error('[RoolSpace] Failed to drop collection:', error);
477
+ this._schema[name] = previous;
427
478
  throw error;
428
479
  }
429
480
  }
@@ -437,9 +488,8 @@ export class RoolSpace extends EventEmitter {
437
488
  async deleteConversation(conversationId) {
438
489
  const targetConversationId = conversationId ?? this._conversationId;
439
490
  // Optimistic local update
440
- if (this._data.conversations?.[targetConversationId]) {
441
- delete this._data.conversations[targetConversationId];
442
- }
491
+ const previous = this._conversations[targetConversationId];
492
+ delete this._conversations[targetConversationId];
443
493
  // Emit events
444
494
  this.emit('conversationUpdated', {
445
495
  conversationId: targetConversationId,
@@ -456,7 +506,8 @@ export class RoolSpace extends EventEmitter {
456
506
  }
457
507
  catch (error) {
458
508
  this.logger.error('[RoolSpace] Failed to delete conversation:', error);
459
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
509
+ if (previous)
510
+ this._conversations[targetConversationId] = previous;
460
511
  throw error;
461
512
  }
462
513
  }
@@ -466,12 +517,10 @@ export class RoolSpace extends EventEmitter {
466
517
  */
467
518
  async renameConversation(conversationId, name) {
468
519
  // Optimistic local update - auto-create if needed
469
- if (!this._data.conversations) {
470
- this._data.conversations = {};
471
- }
472
- const isNew = !this._data.conversations[conversationId];
520
+ const isNew = !this._conversations[conversationId];
521
+ const previous = this._conversations[conversationId];
473
522
  if (isNew) {
474
- this._data.conversations[conversationId] = {
523
+ this._conversations[conversationId] = {
475
524
  name,
476
525
  createdAt: Date.now(),
477
526
  createdBy: this._userId,
@@ -479,7 +528,7 @@ export class RoolSpace extends EventEmitter {
479
528
  };
480
529
  }
481
530
  else {
482
- this._data.conversations[conversationId].name = name;
531
+ this._conversations[conversationId] = { ...this._conversations[conversationId], name };
483
532
  }
484
533
  // Emit events
485
534
  this.emit('conversationUpdated', {
@@ -498,22 +547,35 @@ export class RoolSpace extends EventEmitter {
498
547
  }
499
548
  catch (error) {
500
549
  this.logger.error('[RoolSpace] Failed to rename conversation:', error);
501
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
550
+ if (isNew) {
551
+ delete this._conversations[conversationId];
552
+ }
553
+ else if (previous) {
554
+ this._conversations[conversationId] = previous;
555
+ }
502
556
  throw error;
503
557
  }
504
558
  }
505
559
  /**
506
560
  * List all conversations in this space with summary info.
561
+ * Returns from local cache (kept in sync via SSE).
507
562
  */
508
- async listConversations() {
509
- return this.graphqlClient.listConversations(this.id);
563
+ listConversations() {
564
+ return Object.entries(this._conversations).map(([id, conv]) => ({
565
+ id,
566
+ name: conv.name ?? null,
567
+ createdAt: conv.createdAt,
568
+ createdBy: conv.createdBy,
569
+ createdByName: conv.createdByName ?? null,
570
+ interactionCount: conv.interactions.length,
571
+ }));
510
572
  }
511
573
  /**
512
574
  * Get the system instruction for the current conversation.
513
575
  * Returns undefined if no system instruction is set.
514
576
  */
515
577
  getSystemInstruction() {
516
- return this._data.conversations?.[this._conversationId]?.systemInstruction;
578
+ return this._conversations[this._conversationId]?.systemInstruction;
517
579
  }
518
580
  /**
519
581
  * Set the system instruction for the current conversation.
@@ -521,21 +583,20 @@ export class RoolSpace extends EventEmitter {
521
583
  */
522
584
  async setSystemInstruction(instruction) {
523
585
  // Optimistic local update
524
- if (!this._data.conversations) {
525
- this._data.conversations = {};
526
- }
527
- if (!this._data.conversations[this._conversationId]) {
528
- this._data.conversations[this._conversationId] = {
586
+ if (!this._conversations[this._conversationId]) {
587
+ this._conversations[this._conversationId] = {
529
588
  createdAt: Date.now(),
530
589
  createdBy: this._userId,
531
590
  interactions: [],
532
591
  };
533
592
  }
593
+ const previous = this._conversations[this._conversationId];
534
594
  if (instruction === null) {
535
- delete this._data.conversations[this._conversationId].systemInstruction;
595
+ const { systemInstruction: _, ...rest } = this._conversations[this._conversationId];
596
+ this._conversations[this._conversationId] = rest;
536
597
  }
537
598
  else {
538
- this._data.conversations[this._conversationId].systemInstruction = instruction;
599
+ this._conversations[this._conversationId] = { ...this._conversations[this._conversationId], systemInstruction: instruction };
539
600
  }
540
601
  // Emit event
541
602
  this.emit('conversationUpdated', {
@@ -548,7 +609,7 @@ export class RoolSpace extends EventEmitter {
548
609
  }
549
610
  catch (error) {
550
611
  this.logger.error('[RoolSpace] Failed to set system instruction:', error);
551
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
612
+ this._conversations[this._conversationId] = previous;
552
613
  throw error;
553
614
  }
554
615
  }
@@ -560,29 +621,25 @@ export class RoolSpace extends EventEmitter {
560
621
  * Metadata is stored in meta and hidden from AI operations.
561
622
  */
562
623
  setMetadata(key, value) {
563
- if (!this._data.meta) {
564
- this._data.meta = {};
565
- }
566
- this._data.meta[key] = value;
567
- this.emit('metadataUpdated', { metadata: this._data.meta, source: 'local_user' });
568
- // Fire-and-forget server call - errors trigger resync
569
- this.graphqlClient.setSpaceMeta(this.id, this._data.meta, this._conversationId)
624
+ this._meta[key] = value;
625
+ this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
626
+ // Fire-and-forget server call
627
+ this.graphqlClient.setSpaceMeta(this.id, this._meta, this._conversationId)
570
628
  .catch((error) => {
571
629
  this.logger.error('[RoolSpace] Failed to set meta:', error);
572
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
573
630
  });
574
631
  }
575
632
  /**
576
633
  * Get a space-level metadata value.
577
634
  */
578
635
  getMetadata(key) {
579
- return this._data.meta?.[key];
636
+ return this._meta[key];
580
637
  }
581
638
  /**
582
639
  * Get all space-level metadata.
583
640
  */
584
641
  getAllMetadata() {
585
- return this._data.meta ?? {};
642
+ return this._meta;
586
643
  }
587
644
  // ===========================================================================
588
645
  // AI Operations
@@ -599,10 +656,28 @@ export class RoolSpace extends EventEmitter {
599
656
  attachmentUrls = await Promise.all(attachments.map(file => this.mediaClient.upload(this._id, file)));
600
657
  }
601
658
  const result = await this.graphqlClient.prompt(this._id, prompt, this._conversationId, { ...rest, attachmentUrls });
602
- // Hydrate modified object IDs to actual objects (filter out deleted ones)
603
- const objects = result.modifiedObjectIds
604
- .map(id => this._data.objects[id]?.data)
605
- .filter((obj) => obj !== undefined);
659
+ // Collect modified objects they arrive via SSE events during/after the mutation.
660
+ // Try collecting from buffer first, then fetch any missing from server.
661
+ const objects = [];
662
+ const missing = [];
663
+ for (const id of result.modifiedObjectIds) {
664
+ const buffered = this._objectBuffer.get(id);
665
+ if (buffered) {
666
+ this._objectBuffer.delete(id);
667
+ objects.push(buffered);
668
+ }
669
+ else {
670
+ missing.push(id);
671
+ }
672
+ }
673
+ // Fetch any objects not yet received via SSE
674
+ if (missing.length > 0) {
675
+ const fetched = await Promise.all(missing.map(id => this.graphqlClient.getObject(this._id, id)));
676
+ for (const obj of fetched) {
677
+ if (obj)
678
+ objects.push(obj);
679
+ }
680
+ }
606
681
  return {
607
682
  message: result.message,
608
683
  objects,
@@ -676,13 +751,6 @@ export class RoolSpace extends EventEmitter {
676
751
  // ===========================================================================
677
752
  // Low-level Operations
678
753
  // ===========================================================================
679
- /**
680
- * Get the full space data.
681
- * Use sparingly - prefer specific operations.
682
- */
683
- getData() {
684
- return this._data;
685
- }
686
754
  // ===========================================================================
687
755
  // Import/Export
688
756
  // ===========================================================================
@@ -696,6 +764,64 @@ export class RoolSpace extends EventEmitter {
696
764
  return this.mediaClient.exportArchive(this._id);
697
765
  }
698
766
  // ===========================================================================
767
+ // Object Collection (internal)
768
+ // ===========================================================================
769
+ /**
770
+ * Register a collector that resolves when the object arrives via SSE.
771
+ * If the object is already in the buffer (arrived before collector), resolves immediately.
772
+ * @internal
773
+ */
774
+ _collectObject(objectId) {
775
+ return new Promise((resolve, reject) => {
776
+ // Check buffer first — SSE event may have arrived before the HTTP response
777
+ const buffered = this._objectBuffer.get(objectId);
778
+ if (buffered) {
779
+ this._objectBuffer.delete(objectId);
780
+ resolve(buffered);
781
+ return;
782
+ }
783
+ const timer = setTimeout(() => {
784
+ this._objectResolvers.delete(objectId);
785
+ // Fallback: try to fetch from server
786
+ this.graphqlClient.getObject(this._id, objectId).then(obj => {
787
+ if (obj) {
788
+ resolve(obj);
789
+ }
790
+ else {
791
+ reject(new Error(`Timeout waiting for object ${objectId} from SSE`));
792
+ }
793
+ }).catch(reject);
794
+ }, OBJECT_COLLECT_TIMEOUT);
795
+ this._objectResolvers.set(objectId, (obj) => {
796
+ clearTimeout(timer);
797
+ resolve(obj);
798
+ });
799
+ });
800
+ }
801
+ /**
802
+ * Cancel a pending object collector (e.g., on mutation error).
803
+ * @internal
804
+ */
805
+ _cancelCollector(objectId) {
806
+ this._objectResolvers.delete(objectId);
807
+ this._objectBuffer.delete(objectId);
808
+ }
809
+ /**
810
+ * Deliver an object to a pending collector, or buffer it for later collection.
811
+ * @internal
812
+ */
813
+ _deliverObject(objectId, object) {
814
+ const resolver = this._objectResolvers.get(objectId);
815
+ if (resolver) {
816
+ resolver(object);
817
+ this._objectResolvers.delete(objectId);
818
+ }
819
+ else {
820
+ // Buffer for prompt() or late collectors
821
+ this._objectBuffer.set(objectId, object);
822
+ }
823
+ }
824
+ // ===========================================================================
699
825
  // Event Handlers (internal - handles space subscription events)
700
826
  // ===========================================================================
701
827
  /**
@@ -706,187 +832,142 @@ export class RoolSpace extends EventEmitter {
706
832
  // Ignore events after close - the space is being torn down
707
833
  if (this._closed)
708
834
  return;
835
+ const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
709
836
  switch (event.type) {
710
- case 'space_patched':
711
- if (event.patch) {
712
- this.handleRemotePatch(event.patch, event.source);
837
+ case 'object_created':
838
+ if (event.objectId && event.object) {
839
+ this._handleObjectCreated(event.objectId, event.object, changeSource);
840
+ }
841
+ break;
842
+ case 'object_updated':
843
+ if (event.objectId && event.object) {
844
+ this._handleObjectUpdated(event.objectId, event.object, changeSource);
845
+ }
846
+ break;
847
+ case 'object_deleted':
848
+ if (event.objectId) {
849
+ this._handleObjectDeleted(event.objectId, changeSource);
850
+ }
851
+ break;
852
+ case 'schema_updated':
853
+ if (event.schema) {
854
+ this._schema = event.schema;
855
+ }
856
+ break;
857
+ case 'metadata_updated':
858
+ if (event.metadata) {
859
+ this._meta = event.metadata;
860
+ this.emit('metadataUpdated', { metadata: this._meta, source: changeSource });
861
+ }
862
+ break;
863
+ case 'conversation_updated':
864
+ if (event.conversationId && event.conversation) {
865
+ this._conversations[event.conversationId] = event.conversation;
866
+ this.emit('conversationUpdated', { conversationId: event.conversationId, source: changeSource });
867
+ // Emit conversationsChanged if this is a new conversation
868
+ this.emit('conversationsChanged', {
869
+ action: 'created',
870
+ conversationId: event.conversationId,
871
+ name: event.conversation.name,
872
+ source: changeSource,
873
+ });
874
+ }
875
+ break;
876
+ case 'conversation_deleted':
877
+ if (event.conversationId) {
878
+ delete this._conversations[event.conversationId];
879
+ this.emit('conversationUpdated', { conversationId: event.conversationId, source: changeSource });
880
+ this.emit('conversationsChanged', {
881
+ action: 'deleted',
882
+ conversationId: event.conversationId,
883
+ source: changeSource,
884
+ });
713
885
  }
714
886
  break;
715
887
  case 'space_changed':
716
- // Full reload needed
888
+ // Full reload needed (undo/redo, bulk operations)
717
889
  void this.graphqlClient.getSpace(this._id).then(({ data }) => {
718
- this._data = data;
719
- this.emit('reset', { source: 'remote_user' });
890
+ if (this._closed)
891
+ return;
892
+ this._meta = data.meta ?? {};
893
+ this._schema = data.schema ?? {};
894
+ this._conversations = data.conversations ?? {};
895
+ this._objectIds = data.objectIds ?? [];
896
+ this.emit('reset', { source: changeSource });
720
897
  });
721
898
  break;
722
899
  }
723
900
  }
724
901
  /**
725
- * Check if a patch would actually change the current data.
726
- * Used to deduplicate events when patches don't change anything (e.g., optimistic updates).
902
+ * Handle an object_created SSE event.
903
+ * Deduplicates against optimistic local creates.
727
904
  * @internal
728
905
  */
729
- didPatchChangeAnything(patch) {
730
- for (const op of patch) {
731
- const pathParts = op.path.split('/').filter(p => p);
732
- let current = this._data;
733
- for (const part of pathParts) {
734
- current = current?.[part];
906
+ _handleObjectCreated(objectId, object, source) {
907
+ // Deliver to any pending collector (for mutation return values)
908
+ this._deliverObject(objectId, object);
909
+ // Maintain local ID list — prepend (most recently modified first)
910
+ this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
911
+ const pending = this._pendingMutations.get(objectId);
912
+ if (pending !== undefined) {
913
+ // This is our own mutation echoed back
914
+ this._pendingMutations.delete(objectId);
915
+ if (pending !== null) {
916
+ // It was a create — already emitted objectCreated optimistically.
917
+ // Emit objectUpdated only if AI resolved placeholders (data changed).
918
+ if (JSON.stringify(pending) !== JSON.stringify(object)) {
919
+ this.emit('objectUpdated', { objectId, object, source });
920
+ }
735
921
  }
736
- if (op.op === 'remove' && current !== undefined)
737
- return true;
738
- if ((op.op === 'add' || op.op === 'replace') &&
739
- JSON.stringify(current) !== JSON.stringify(op.value))
740
- return true;
741
922
  }
742
- return false;
923
+ else {
924
+ // Remote event — emit normally
925
+ this.emit('objectCreated', { objectId, object, source });
926
+ }
743
927
  }
744
928
  /**
745
- * Handle a patch event from another client.
746
- * Checks for version gaps to detect missed patches.
929
+ * Handle an object_updated SSE event.
930
+ * Deduplicates against optimistic local updates.
747
931
  * @internal
748
932
  */
749
- handleRemotePatch(patch, source) {
750
- // Extract the new version from the patch
751
- const versionOp = patch.find(op => op.path === '/version' && (op.op === 'add' || op.op === 'replace'));
752
- if (versionOp) {
753
- const incomingVersion = versionOp.value;
754
- const currentVersion = this._data.version ?? 0;
755
- const expectedVersion = currentVersion + 1;
756
- // Check for version gap (missed patches)
757
- if (incomingVersion > expectedVersion) {
758
- this.logger.warn(`[RoolSpace] Version gap detected: expected ${expectedVersion}, got ${incomingVersion}. Resyncing.`);
759
- this.resyncFromServer(new Error(`Version gap: expected ${expectedVersion}, got ${incomingVersion}`))
760
- .catch(() => { });
761
- return;
762
- }
763
- // Skip stale patches (version <= current, already applied)
764
- if (incomingVersion <= currentVersion) {
765
- return;
933
+ _handleObjectUpdated(objectId, object, source) {
934
+ // Deliver to any pending collector
935
+ this._deliverObject(objectId, object);
936
+ // Maintain local ID list — move to front (most recently modified)
937
+ this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
938
+ const pending = this._pendingMutations.get(objectId);
939
+ if (pending !== undefined) {
940
+ // This is our own mutation echoed back
941
+ this._pendingMutations.delete(objectId);
942
+ if (pending !== null) {
943
+ // Already emitted objectUpdated optimistically.
944
+ // Emit again only if data changed (AI resolved placeholders).
945
+ if (JSON.stringify(pending) !== JSON.stringify(object)) {
946
+ this.emit('objectUpdated', { objectId, object, source });
947
+ }
766
948
  }
767
949
  }
768
- // Check if patch would change anything BEFORE applying
769
- const willChange = this.didPatchChangeAnything(patch);
770
- try {
771
- this._data = immutableJSONPatch(this._data, patch);
772
- }
773
- catch (error) {
774
- this.logger.error('[RoolSpace] Failed to apply remote patch:', error);
775
- // Force resync on patch error
776
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
777
- return;
778
- }
779
- // Only emit events if something actually changed
780
- if (willChange) {
781
- const changeSource = source === 'agent' ? 'remote_agent' : 'remote_user';
782
- this.emitSemanticEventsFromPatch(patch, changeSource);
950
+ else {
951
+ // Remote event
952
+ this.emit('objectUpdated', { objectId, object, source });
783
953
  }
784
954
  }
785
955
  /**
786
- * Parse JSON patch operations and emit semantic events.
956
+ * Handle an object_deleted SSE event.
957
+ * Deduplicates against optimistic local deletes.
787
958
  * @internal
788
959
  */
789
- emitSemanticEventsFromPatch(patch, source) {
790
- // Track which objects have been updated (to avoid duplicate events)
791
- const updatedObjects = new Set();
792
- for (const op of patch) {
793
- const { path } = op;
794
- // Object operations: /objects/{objectId}/...
795
- if (path.startsWith('/objects/')) {
796
- const parts = path.split('/');
797
- const objectId = parts[2];
798
- if (parts.length === 3) {
799
- // /objects/{objectId} - full object add or remove
800
- if (op.op === 'add') {
801
- const entry = this._data.objects[objectId];
802
- if (entry) {
803
- this.emit('objectCreated', { objectId, object: entry.data, source });
804
- }
805
- }
806
- else if (op.op === 'remove') {
807
- this.emit('objectDeleted', { objectId, source });
808
- }
809
- }
810
- else if (parts[3] === 'data') {
811
- // /objects/{objectId}/data/... - data field update
812
- if (!updatedObjects.has(objectId)) {
813
- const entry = this._data.objects[objectId];
814
- if (entry) {
815
- this.emit('objectUpdated', { objectId, object: entry.data, source });
816
- updatedObjects.add(objectId);
817
- }
818
- }
819
- }
820
- }
821
- else if (path === '/meta' || path.startsWith('/meta/')) {
822
- this.emit('metadataUpdated', { metadata: this._data.meta, source });
823
- }
824
- // Conversation operations: /conversations/{conversationId} or /conversations/{conversationId}/...
825
- else if (path.startsWith('/conversations/')) {
826
- const parts = path.split('/');
827
- const conversationId = parts[2];
828
- if (conversationId) {
829
- this.emit('conversationUpdated', { conversationId, source });
830
- // Emit conversationsChanged for list-level changes
831
- if (parts.length === 3) {
832
- // /conversations/{conversationId} - full conversation add or remove
833
- if (op.op === 'add') {
834
- const conv = this._data.conversations?.[conversationId];
835
- this.emit('conversationsChanged', {
836
- action: 'created',
837
- conversationId,
838
- name: conv?.name,
839
- source,
840
- });
841
- }
842
- else if (op.op === 'remove') {
843
- this.emit('conversationsChanged', {
844
- action: 'deleted',
845
- conversationId,
846
- source,
847
- });
848
- }
849
- }
850
- else if (parts[3] === 'name') {
851
- // /conversations/{conversationId}/name - rename
852
- const conv = this._data.conversations?.[conversationId];
853
- this.emit('conversationsChanged', {
854
- action: 'renamed',
855
- conversationId,
856
- name: conv?.name,
857
- source,
858
- });
859
- }
860
- }
861
- }
960
+ _handleObjectDeleted(objectId, source) {
961
+ // Remove from local ID list
962
+ this._objectIds = this._objectIds.filter(id => id !== objectId);
963
+ const pending = this._pendingMutations.get(objectId);
964
+ if (pending !== undefined) {
965
+ // This is our own delete echoed back — already emitted
966
+ this._pendingMutations.delete(objectId);
862
967
  }
863
- }
864
- // ===========================================================================
865
- // Private Methods
866
- // ===========================================================================
867
- async resyncFromServer(originalError) {
868
- this.logger.warn('[RoolSpace] Resyncing from server after sync failure');
869
- try {
870
- const { data } = await this.graphqlClient.getSpace(this._id);
871
- // Check again after await - space might have been closed during fetch
872
- if (this._closed)
873
- return;
874
- this._data = data;
875
- // Clear history is now async but we don't need to wait for it during resync
876
- // (it's a server-side cleanup that can happen in background)
877
- this.clearHistory().catch((err) => {
878
- this.logger.warn('[RoolSpace] Failed to clear history during resync:', err);
879
- });
880
- this.emit('syncError', originalError ?? new Error('Sync failed'));
881
- this.emit('reset', { source: 'system' });
882
- }
883
- catch (error) {
884
- // If space was closed during fetch, don't log error - expected during teardown
885
- if (this._closed)
886
- return;
887
- this.logger.error('[RoolSpace] Failed to resync from server:', error);
888
- // Still emit syncError with the original error
889
- this.emit('syncError', originalError ?? new Error('Sync failed'));
968
+ else {
969
+ // Remote event
970
+ this.emit('objectDeleted', { objectId, source });
890
971
  }
891
972
  }
892
973
  }