@rool-dev/sdk 0.2.0-dev.0d7e105 → 0.2.0-dev.64c2b97

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)
361
+ // Emit optimistic event if we have data changes
375
362
  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
386
- 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,21 @@ 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' });
427
404
  throw error;
428
405
  }
429
406
  }
@@ -435,51 +412,49 @@ export class RoolSpace extends EventEmitter {
435
412
  * Returns a map of collection names to their definitions.
436
413
  */
437
414
  getSchema() {
438
- return this._data.schema ?? {};
415
+ return this._schema;
439
416
  }
440
417
  /**
441
418
  * Create a new collection schema.
442
419
  * @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
443
- * @param props - Property definitions for the collection
420
+ * @param fields - Field definitions for the collection
444
421
  * @returns The created CollectionDef
445
422
  */
446
- async createCollection(name, props) {
447
- if (this._data.schema?.[name]) {
423
+ async createCollection(name, fields) {
424
+ if (this._schema[name]) {
448
425
  throw new Error(`Collection "${name}" already exists`);
449
426
  }
450
427
  // Optimistic local update
451
- if (!this._data.schema) {
452
- this._data.schema = {};
453
- }
454
- const optimisticDef = { props: props.map(p => ({ name: p.name, type: p.type })) };
455
- this._data.schema[name] = optimisticDef;
428
+ const optimisticDef = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
429
+ this._schema[name] = optimisticDef;
456
430
  try {
457
- return await this.graphqlClient.createCollection(this._id, name, props, this._conversationId);
431
+ return await this.graphqlClient.createCollection(this._id, name, fields, this._conversationId);
458
432
  }
459
433
  catch (error) {
460
434
  this.logger.error('[RoolSpace] Failed to create collection:', error);
461
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
435
+ delete this._schema[name];
462
436
  throw error;
463
437
  }
464
438
  }
465
439
  /**
466
- * Alter an existing collection schema, replacing its property definitions.
440
+ * Alter an existing collection schema, replacing its field definitions.
467
441
  * @param name - Name of the collection to alter
468
- * @param props - New property definitions (replaces all existing props)
442
+ * @param fields - New field definitions (replaces all existing fields)
469
443
  * @returns The updated CollectionDef
470
444
  */
471
- async alterCollection(name, props) {
472
- if (!this._data.schema?.[name]) {
445
+ async alterCollection(name, fields) {
446
+ if (!this._schema[name]) {
473
447
  throw new Error(`Collection "${name}" not found`);
474
448
  }
449
+ const previous = this._schema[name];
475
450
  // Optimistic local update
476
- this._data.schema[name] = { props: props.map(p => ({ name: p.name, type: p.type })) };
451
+ this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
477
452
  try {
478
- return await this.graphqlClient.alterCollection(this._id, name, props, this._conversationId);
453
+ return await this.graphqlClient.alterCollection(this._id, name, fields, this._conversationId);
479
454
  }
480
455
  catch (error) {
481
456
  this.logger.error('[RoolSpace] Failed to alter collection:', error);
482
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
457
+ this._schema[name] = previous;
483
458
  throw error;
484
459
  }
485
460
  }
@@ -488,17 +463,18 @@ export class RoolSpace extends EventEmitter {
488
463
  * @param name - Name of the collection to drop
489
464
  */
490
465
  async dropCollection(name) {
491
- if (!this._data.schema?.[name]) {
466
+ if (!this._schema[name]) {
492
467
  throw new Error(`Collection "${name}" not found`);
493
468
  }
469
+ const previous = this._schema[name];
494
470
  // Optimistic local update
495
- delete this._data.schema[name];
471
+ delete this._schema[name];
496
472
  try {
497
473
  await this.graphqlClient.dropCollection(this._id, name, this._conversationId);
498
474
  }
499
475
  catch (error) {
500
476
  this.logger.error('[RoolSpace] Failed to drop collection:', error);
501
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
477
+ this._schema[name] = previous;
502
478
  throw error;
503
479
  }
504
480
  }
@@ -512,9 +488,8 @@ export class RoolSpace extends EventEmitter {
512
488
  async deleteConversation(conversationId) {
513
489
  const targetConversationId = conversationId ?? this._conversationId;
514
490
  // Optimistic local update
515
- if (this._data.conversations?.[targetConversationId]) {
516
- delete this._data.conversations[targetConversationId];
517
- }
491
+ const previous = this._conversations[targetConversationId];
492
+ delete this._conversations[targetConversationId];
518
493
  // Emit events
519
494
  this.emit('conversationUpdated', {
520
495
  conversationId: targetConversationId,
@@ -531,7 +506,8 @@ export class RoolSpace extends EventEmitter {
531
506
  }
532
507
  catch (error) {
533
508
  this.logger.error('[RoolSpace] Failed to delete conversation:', error);
534
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
509
+ if (previous)
510
+ this._conversations[targetConversationId] = previous;
535
511
  throw error;
536
512
  }
537
513
  }
@@ -541,12 +517,10 @@ export class RoolSpace extends EventEmitter {
541
517
  */
542
518
  async renameConversation(conversationId, name) {
543
519
  // Optimistic local update - auto-create if needed
544
- if (!this._data.conversations) {
545
- this._data.conversations = {};
546
- }
547
- const isNew = !this._data.conversations[conversationId];
520
+ const isNew = !this._conversations[conversationId];
521
+ const previous = this._conversations[conversationId];
548
522
  if (isNew) {
549
- this._data.conversations[conversationId] = {
523
+ this._conversations[conversationId] = {
550
524
  name,
551
525
  createdAt: Date.now(),
552
526
  createdBy: this._userId,
@@ -554,7 +528,7 @@ export class RoolSpace extends EventEmitter {
554
528
  };
555
529
  }
556
530
  else {
557
- this._data.conversations[conversationId].name = name;
531
+ this._conversations[conversationId] = { ...this._conversations[conversationId], name };
558
532
  }
559
533
  // Emit events
560
534
  this.emit('conversationUpdated', {
@@ -573,22 +547,35 @@ export class RoolSpace extends EventEmitter {
573
547
  }
574
548
  catch (error) {
575
549
  this.logger.error('[RoolSpace] Failed to rename conversation:', error);
576
- 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
+ }
577
556
  throw error;
578
557
  }
579
558
  }
580
559
  /**
581
560
  * List all conversations in this space with summary info.
561
+ * Returns from local cache (kept in sync via SSE).
582
562
  */
583
- async listConversations() {
584
- 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
+ }));
585
572
  }
586
573
  /**
587
574
  * Get the system instruction for the current conversation.
588
575
  * Returns undefined if no system instruction is set.
589
576
  */
590
577
  getSystemInstruction() {
591
- return this._data.conversations?.[this._conversationId]?.systemInstruction;
578
+ return this._conversations[this._conversationId]?.systemInstruction;
592
579
  }
593
580
  /**
594
581
  * Set the system instruction for the current conversation.
@@ -596,21 +583,20 @@ export class RoolSpace extends EventEmitter {
596
583
  */
597
584
  async setSystemInstruction(instruction) {
598
585
  // Optimistic local update
599
- if (!this._data.conversations) {
600
- this._data.conversations = {};
601
- }
602
- if (!this._data.conversations[this._conversationId]) {
603
- this._data.conversations[this._conversationId] = {
586
+ if (!this._conversations[this._conversationId]) {
587
+ this._conversations[this._conversationId] = {
604
588
  createdAt: Date.now(),
605
589
  createdBy: this._userId,
606
590
  interactions: [],
607
591
  };
608
592
  }
593
+ const previous = this._conversations[this._conversationId];
609
594
  if (instruction === null) {
610
- delete this._data.conversations[this._conversationId].systemInstruction;
595
+ const { systemInstruction: _, ...rest } = this._conversations[this._conversationId];
596
+ this._conversations[this._conversationId] = rest;
611
597
  }
612
598
  else {
613
- this._data.conversations[this._conversationId].systemInstruction = instruction;
599
+ this._conversations[this._conversationId] = { ...this._conversations[this._conversationId], systemInstruction: instruction };
614
600
  }
615
601
  // Emit event
616
602
  this.emit('conversationUpdated', {
@@ -623,7 +609,7 @@ export class RoolSpace extends EventEmitter {
623
609
  }
624
610
  catch (error) {
625
611
  this.logger.error('[RoolSpace] Failed to set system instruction:', error);
626
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
612
+ this._conversations[this._conversationId] = previous;
627
613
  throw error;
628
614
  }
629
615
  }
@@ -635,29 +621,25 @@ export class RoolSpace extends EventEmitter {
635
621
  * Metadata is stored in meta and hidden from AI operations.
636
622
  */
637
623
  setMetadata(key, value) {
638
- if (!this._data.meta) {
639
- this._data.meta = {};
640
- }
641
- this._data.meta[key] = value;
642
- this.emit('metadataUpdated', { metadata: this._data.meta, source: 'local_user' });
643
- // Fire-and-forget server call - errors trigger resync
644
- 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)
645
628
  .catch((error) => {
646
629
  this.logger.error('[RoolSpace] Failed to set meta:', error);
647
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
648
630
  });
649
631
  }
650
632
  /**
651
633
  * Get a space-level metadata value.
652
634
  */
653
635
  getMetadata(key) {
654
- return this._data.meta?.[key];
636
+ return this._meta[key];
655
637
  }
656
638
  /**
657
639
  * Get all space-level metadata.
658
640
  */
659
641
  getAllMetadata() {
660
- return this._data.meta ?? {};
642
+ return this._meta;
661
643
  }
662
644
  // ===========================================================================
663
645
  // AI Operations
@@ -674,10 +656,28 @@ export class RoolSpace extends EventEmitter {
674
656
  attachmentUrls = await Promise.all(attachments.map(file => this.mediaClient.upload(this._id, file)));
675
657
  }
676
658
  const result = await this.graphqlClient.prompt(this._id, prompt, this._conversationId, { ...rest, attachmentUrls });
677
- // Hydrate modified object IDs to actual objects (filter out deleted ones)
678
- const objects = result.modifiedObjectIds
679
- .map(id => this._data.objects[id]?.data)
680
- .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
+ }
681
681
  return {
682
682
  message: result.message,
683
683
  objects,
@@ -751,13 +751,6 @@ export class RoolSpace extends EventEmitter {
751
751
  // ===========================================================================
752
752
  // Low-level Operations
753
753
  // ===========================================================================
754
- /**
755
- * Get the full space data.
756
- * Use sparingly - prefer specific operations.
757
- */
758
- getData() {
759
- return this._data;
760
- }
761
754
  // ===========================================================================
762
755
  // Import/Export
763
756
  // ===========================================================================
@@ -771,6 +764,64 @@ export class RoolSpace extends EventEmitter {
771
764
  return this.mediaClient.exportArchive(this._id);
772
765
  }
773
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
+ // ===========================================================================
774
825
  // Event Handlers (internal - handles space subscription events)
775
826
  // ===========================================================================
776
827
  /**
@@ -781,187 +832,142 @@ export class RoolSpace extends EventEmitter {
781
832
  // Ignore events after close - the space is being torn down
782
833
  if (this._closed)
783
834
  return;
835
+ const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
784
836
  switch (event.type) {
785
- case 'space_patched':
786
- if (event.patch) {
787
- 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
+ });
788
885
  }
789
886
  break;
790
887
  case 'space_changed':
791
- // Full reload needed
888
+ // Full reload needed (undo/redo, bulk operations)
792
889
  void this.graphqlClient.getSpace(this._id).then(({ data }) => {
793
- this._data = data;
794
- 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 });
795
897
  });
796
898
  break;
797
899
  }
798
900
  }
799
901
  /**
800
- * Check if a patch would actually change the current data.
801
- * 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.
802
904
  * @internal
803
905
  */
804
- didPatchChangeAnything(patch) {
805
- for (const op of patch) {
806
- const pathParts = op.path.split('/').filter(p => p);
807
- let current = this._data;
808
- for (const part of pathParts) {
809
- 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
+ }
810
921
  }
811
- if (op.op === 'remove' && current !== undefined)
812
- return true;
813
- if ((op.op === 'add' || op.op === 'replace') &&
814
- JSON.stringify(current) !== JSON.stringify(op.value))
815
- return true;
816
922
  }
817
- return false;
923
+ else {
924
+ // Remote event — emit normally
925
+ this.emit('objectCreated', { objectId, object, source });
926
+ }
818
927
  }
819
928
  /**
820
- * Handle a patch event from another client.
821
- * Checks for version gaps to detect missed patches.
929
+ * Handle an object_updated SSE event.
930
+ * Deduplicates against optimistic local updates.
822
931
  * @internal
823
932
  */
824
- handleRemotePatch(patch, source) {
825
- // Extract the new version from the patch
826
- const versionOp = patch.find(op => op.path === '/version' && (op.op === 'add' || op.op === 'replace'));
827
- if (versionOp) {
828
- const incomingVersion = versionOp.value;
829
- const currentVersion = this._data.version ?? 0;
830
- const expectedVersion = currentVersion + 1;
831
- // Check for version gap (missed patches)
832
- if (incomingVersion > expectedVersion) {
833
- this.logger.warn(`[RoolSpace] Version gap detected: expected ${expectedVersion}, got ${incomingVersion}. Resyncing.`);
834
- this.resyncFromServer(new Error(`Version gap: expected ${expectedVersion}, got ${incomingVersion}`))
835
- .catch(() => { });
836
- return;
837
- }
838
- // Skip stale patches (version <= current, already applied)
839
- if (incomingVersion <= currentVersion) {
840
- 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
+ }
841
948
  }
842
949
  }
843
- // Check if patch would change anything BEFORE applying
844
- const willChange = this.didPatchChangeAnything(patch);
845
- try {
846
- this._data = immutableJSONPatch(this._data, patch);
847
- }
848
- catch (error) {
849
- this.logger.error('[RoolSpace] Failed to apply remote patch:', error);
850
- // Force resync on patch error
851
- this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
852
- return;
853
- }
854
- // Only emit events if something actually changed
855
- if (willChange) {
856
- const changeSource = source === 'agent' ? 'remote_agent' : 'remote_user';
857
- this.emitSemanticEventsFromPatch(patch, changeSource);
950
+ else {
951
+ // Remote event
952
+ this.emit('objectUpdated', { objectId, object, source });
858
953
  }
859
954
  }
860
955
  /**
861
- * Parse JSON patch operations and emit semantic events.
956
+ * Handle an object_deleted SSE event.
957
+ * Deduplicates against optimistic local deletes.
862
958
  * @internal
863
959
  */
864
- emitSemanticEventsFromPatch(patch, source) {
865
- // Track which objects have been updated (to avoid duplicate events)
866
- const updatedObjects = new Set();
867
- for (const op of patch) {
868
- const { path } = op;
869
- // Object operations: /objects/{objectId}/...
870
- if (path.startsWith('/objects/')) {
871
- const parts = path.split('/');
872
- const objectId = parts[2];
873
- if (parts.length === 3) {
874
- // /objects/{objectId} - full object add or remove
875
- if (op.op === 'add') {
876
- const entry = this._data.objects[objectId];
877
- if (entry) {
878
- this.emit('objectCreated', { objectId, object: entry.data, source });
879
- }
880
- }
881
- else if (op.op === 'remove') {
882
- this.emit('objectDeleted', { objectId, source });
883
- }
884
- }
885
- else if (parts[3] === 'data') {
886
- // /objects/{objectId}/data/... - data field update
887
- if (!updatedObjects.has(objectId)) {
888
- const entry = this._data.objects[objectId];
889
- if (entry) {
890
- this.emit('objectUpdated', { objectId, object: entry.data, source });
891
- updatedObjects.add(objectId);
892
- }
893
- }
894
- }
895
- }
896
- else if (path === '/meta' || path.startsWith('/meta/')) {
897
- this.emit('metadataUpdated', { metadata: this._data.meta, source });
898
- }
899
- // Conversation operations: /conversations/{conversationId} or /conversations/{conversationId}/...
900
- else if (path.startsWith('/conversations/')) {
901
- const parts = path.split('/');
902
- const conversationId = parts[2];
903
- if (conversationId) {
904
- this.emit('conversationUpdated', { conversationId, source });
905
- // Emit conversationsChanged for list-level changes
906
- if (parts.length === 3) {
907
- // /conversations/{conversationId} - full conversation add or remove
908
- if (op.op === 'add') {
909
- const conv = this._data.conversations?.[conversationId];
910
- this.emit('conversationsChanged', {
911
- action: 'created',
912
- conversationId,
913
- name: conv?.name,
914
- source,
915
- });
916
- }
917
- else if (op.op === 'remove') {
918
- this.emit('conversationsChanged', {
919
- action: 'deleted',
920
- conversationId,
921
- source,
922
- });
923
- }
924
- }
925
- else if (parts[3] === 'name') {
926
- // /conversations/{conversationId}/name - rename
927
- const conv = this._data.conversations?.[conversationId];
928
- this.emit('conversationsChanged', {
929
- action: 'renamed',
930
- conversationId,
931
- name: conv?.name,
932
- source,
933
- });
934
- }
935
- }
936
- }
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);
937
967
  }
938
- }
939
- // ===========================================================================
940
- // Private Methods
941
- // ===========================================================================
942
- async resyncFromServer(originalError) {
943
- this.logger.warn('[RoolSpace] Resyncing from server after sync failure');
944
- try {
945
- const { data } = await this.graphqlClient.getSpace(this._id);
946
- // Check again after await - space might have been closed during fetch
947
- if (this._closed)
948
- return;
949
- this._data = data;
950
- // Clear history is now async but we don't need to wait for it during resync
951
- // (it's a server-side cleanup that can happen in background)
952
- this.clearHistory().catch((err) => {
953
- this.logger.warn('[RoolSpace] Failed to clear history during resync:', err);
954
- });
955
- this.emit('syncError', originalError ?? new Error('Sync failed'));
956
- this.emit('reset', { source: 'system' });
957
- }
958
- catch (error) {
959
- // If space was closed during fetch, don't log error - expected during teardown
960
- if (this._closed)
961
- return;
962
- this.logger.error('[RoolSpace] Failed to resync from server:', error);
963
- // Still emit syncError with the original error
964
- this.emit('syncError', originalError ?? new Error('Sync failed'));
968
+ else {
969
+ // Remote event
970
+ this.emit('objectDeleted', { objectId, source });
965
971
  }
966
972
  }
967
973
  }