@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/README.md +85 -24
- package/dist/graphql.d.ts +12 -6
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +36 -30
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/space.d.ts +48 -44
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +326 -320
- package/dist/space.js.map +1 -1
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +15 -20
- package/dist/subscription.js.map +1 -1
- package/dist/types.d.ts +19 -50
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -2
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
251
|
+
* Fetches from the server on each call.
|
|
229
252
|
*/
|
|
230
253
|
async getObject(objectId) {
|
|
231
|
-
return this.
|
|
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(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
335
|
-
//
|
|
336
|
-
|
|
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.
|
|
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
|
-
//
|
|
361
|
+
// Emit optimistic event if we have data changes
|
|
375
362
|
if (data) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
393
|
-
return { object
|
|
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.
|
|
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
|
-
|
|
409
|
-
// Remove objects (local state)
|
|
389
|
+
// Track for dedup and emit optimistic events
|
|
410
390
|
for (const objectId of objectIds) {
|
|
411
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
420
|
+
* @param fields - Field definitions for the collection
|
|
444
421
|
* @returns The created CollectionDef
|
|
445
422
|
*/
|
|
446
|
-
async createCollection(name,
|
|
447
|
-
if (this.
|
|
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
|
-
|
|
452
|
-
|
|
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,
|
|
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.
|
|
435
|
+
delete this._schema[name];
|
|
462
436
|
throw error;
|
|
463
437
|
}
|
|
464
438
|
}
|
|
465
439
|
/**
|
|
466
|
-
* Alter an existing collection schema, replacing its
|
|
440
|
+
* Alter an existing collection schema, replacing its field definitions.
|
|
467
441
|
* @param name - Name of the collection to alter
|
|
468
|
-
* @param
|
|
442
|
+
* @param fields - New field definitions (replaces all existing fields)
|
|
469
443
|
* @returns The updated CollectionDef
|
|
470
444
|
*/
|
|
471
|
-
async alterCollection(name,
|
|
472
|
-
if (!this.
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
516
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
584
|
-
return this.
|
|
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.
|
|
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.
|
|
600
|
-
this.
|
|
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
|
-
|
|
595
|
+
const { systemInstruction: _, ...rest } = this._conversations[this._conversationId];
|
|
596
|
+
this._conversations[this._conversationId] = rest;
|
|
611
597
|
}
|
|
612
598
|
else {
|
|
613
|
-
this.
|
|
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.
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
this.
|
|
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.
|
|
636
|
+
return this._meta[key];
|
|
655
637
|
}
|
|
656
638
|
/**
|
|
657
639
|
* Get all space-level metadata.
|
|
658
640
|
*/
|
|
659
641
|
getAllMetadata() {
|
|
660
|
-
return this.
|
|
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
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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 '
|
|
786
|
-
if (event.
|
|
787
|
-
this.
|
|
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.
|
|
794
|
-
|
|
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
|
-
*
|
|
801
|
-
*
|
|
902
|
+
* Handle an object_created SSE event.
|
|
903
|
+
* Deduplicates against optimistic local creates.
|
|
802
904
|
* @internal
|
|
803
905
|
*/
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
923
|
+
else {
|
|
924
|
+
// Remote event — emit normally
|
|
925
|
+
this.emit('objectCreated', { objectId, object, source });
|
|
926
|
+
}
|
|
818
927
|
}
|
|
819
928
|
/**
|
|
820
|
-
* Handle
|
|
821
|
-
*
|
|
929
|
+
* Handle an object_updated SSE event.
|
|
930
|
+
* Deduplicates against optimistic local updates.
|
|
822
931
|
* @internal
|
|
823
932
|
*/
|
|
824
|
-
|
|
825
|
-
//
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
*
|
|
956
|
+
* Handle an object_deleted SSE event.
|
|
957
|
+
* Deduplicates against optimistic local deletes.
|
|
862
958
|
* @internal
|
|
863
959
|
*/
|
|
864
|
-
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
//
|
|
870
|
-
|
|
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
|
-
|
|
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
|
}
|