@rool-dev/sdk 0.2.0-dev.29da71e → 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 +13 -4
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +76 -22
- 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 +67 -39
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +380 -299
- 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 +55 -41
- 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
|
-
//
|
|
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
|
-
|
|
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,95 @@ 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' });
|
|
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 fields - Field definitions for the collection
|
|
421
|
+
* @returns The created CollectionDef
|
|
422
|
+
*/
|
|
423
|
+
async createCollection(name, fields) {
|
|
424
|
+
if (this._schema[name]) {
|
|
425
|
+
throw new Error(`Collection "${name}" already exists`);
|
|
426
|
+
}
|
|
427
|
+
// Optimistic local update
|
|
428
|
+
const optimisticDef = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
|
|
429
|
+
this._schema[name] = optimisticDef;
|
|
430
|
+
try {
|
|
431
|
+
return await this.graphqlClient.createCollection(this._id, name, fields, 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 field definitions.
|
|
441
|
+
* @param name - Name of the collection to alter
|
|
442
|
+
* @param fields - New field definitions (replaces all existing fields)
|
|
443
|
+
* @returns The updated CollectionDef
|
|
444
|
+
*/
|
|
445
|
+
async alterCollection(name, fields) {
|
|
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] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
|
|
452
|
+
try {
|
|
453
|
+
return await this.graphqlClient.alterCollection(this._id, name, fields, 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
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
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
|
+
}));
|
|
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.
|
|
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.
|
|
525
|
-
this.
|
|
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
|
-
|
|
595
|
+
const { systemInstruction: _, ...rest } = this._conversations[this._conversationId];
|
|
596
|
+
this._conversations[this._conversationId] = rest;
|
|
536
597
|
}
|
|
537
598
|
else {
|
|
538
|
-
this.
|
|
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.
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
this.
|
|
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.
|
|
636
|
+
return this._meta[key];
|
|
580
637
|
}
|
|
581
638
|
/**
|
|
582
639
|
* Get all space-level metadata.
|
|
583
640
|
*/
|
|
584
641
|
getAllMetadata() {
|
|
585
|
-
return this.
|
|
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
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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 '
|
|
711
|
-
if (event.
|
|
712
|
-
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
|
+
});
|
|
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.
|
|
719
|
-
|
|
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
|
-
*
|
|
726
|
-
*
|
|
902
|
+
* Handle an object_created SSE event.
|
|
903
|
+
* Deduplicates against optimistic local creates.
|
|
727
904
|
* @internal
|
|
728
905
|
*/
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
923
|
+
else {
|
|
924
|
+
// Remote event — emit normally
|
|
925
|
+
this.emit('objectCreated', { objectId, object, source });
|
|
926
|
+
}
|
|
743
927
|
}
|
|
744
928
|
/**
|
|
745
|
-
* Handle
|
|
746
|
-
*
|
|
929
|
+
* Handle an object_updated SSE event.
|
|
930
|
+
* Deduplicates against optimistic local updates.
|
|
747
931
|
* @internal
|
|
748
932
|
*/
|
|
749
|
-
|
|
750
|
-
//
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
*
|
|
956
|
+
* Handle an object_deleted SSE event.
|
|
957
|
+
* Deduplicates against optimistic local deletes.
|
|
787
958
|
* @internal
|
|
788
959
|
*/
|
|
789
|
-
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
//
|
|
795
|
-
|
|
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
|
-
|
|
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
|
}
|