@rool-dev/sdk 0.2.0-dev.f798776 → 0.3.0-dev.be25932

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.
@@ -0,0 +1,793 @@
1
+ import { EventEmitter } from './event-emitter.js';
2
+ import { ChannelSubscriptionManager } from './subscription.js';
3
+ // 6-character alphanumeric ID (62^6 = 56.8 billion possible values)
4
+ const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
5
+ export function generateEntityId() {
6
+ let result = '';
7
+ for (let i = 0; i < 6; i++) {
8
+ result += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
9
+ }
10
+ return result;
11
+ }
12
+ // Default timeout for waiting on SSE object events (30 seconds)
13
+ const OBJECT_COLLECT_TIMEOUT = 30000;
14
+ /**
15
+ * A channel is a space + channelId pair.
16
+ *
17
+ * All object operations go through a channel. The channelId is fixed
18
+ * at open time and cannot be changed. To use a different channel,
19
+ * open a second one.
20
+ *
21
+ * Objects are fetched on demand from the server; only schema, metadata,
22
+ * and the channel's own conversation are cached locally. Object changes
23
+ * arrive via SSE semantic events and are emitted as SDK events.
24
+ *
25
+ * Features:
26
+ * - High-level object operations
27
+ * - Built-in undo/redo with checkpoints
28
+ * - Metadata management
29
+ * - Event emission for state changes
30
+ * - Real-time updates via space-specific subscription
31
+ */
32
+ export class RoolChannel extends EventEmitter {
33
+ _id;
34
+ _name;
35
+ _role;
36
+ _linkAccess;
37
+ _userId;
38
+ _channelId;
39
+ _closed = false;
40
+ graphqlClient;
41
+ mediaClient;
42
+ subscriptionManager;
43
+ onCloseCallback;
44
+ _subscriptionReady;
45
+ logger;
46
+ // Local cache for bounded data (schema, metadata, own conversation, object IDs, stats)
47
+ _meta;
48
+ _schema;
49
+ _conversation;
50
+ _objectIds;
51
+ _objectStats;
52
+ // Object collection: tracks pending local mutations for dedup
53
+ // Maps objectId → optimistic object data (for create/update) or null (for delete)
54
+ _pendingMutations = new Map();
55
+ // Resolvers waiting for object data from SSE events
56
+ _objectResolvers = new Map();
57
+ // Buffer for object data that arrived before a collector was registered
58
+ _objectBuffer = new Map();
59
+ constructor(config) {
60
+ super();
61
+ this._id = config.id;
62
+ this._name = config.name;
63
+ this._role = config.role;
64
+ this._linkAccess = config.linkAccess;
65
+ this._userId = config.userId;
66
+ this._emitterLogger = config.logger;
67
+ this._channelId = config.channelId;
68
+ this.graphqlClient = config.graphqlClient;
69
+ this.mediaClient = config.mediaClient;
70
+ this.logger = config.logger;
71
+ this.onCloseCallback = config.onClose;
72
+ // Initialize local cache from server data
73
+ this._meta = config.meta;
74
+ this._schema = config.schema;
75
+ this._conversation = config.channel;
76
+ this._objectIds = config.objectIds;
77
+ this._objectStats = new Map(Object.entries(config.objectStats));
78
+ // Create channel subscription
79
+ this.subscriptionManager = new ChannelSubscriptionManager({
80
+ graphqlUrl: config.graphqlUrl,
81
+ authManager: config.authManager,
82
+ logger: this.logger,
83
+ spaceId: this._id,
84
+ channelId: this._channelId,
85
+ onEvent: (event) => this.handleChannelEvent(event),
86
+ onConnectionStateChanged: () => {
87
+ // Channel connection state (could emit events if needed)
88
+ },
89
+ onError: (error) => {
90
+ this.logger.error(`[RoolChannel ${this._id}] Subscription error:`, error);
91
+ },
92
+ });
93
+ // Start subscription - store promise for openChannel to await
94
+ this._subscriptionReady = this.subscriptionManager.subscribe();
95
+ }
96
+ /**
97
+ * Wait for the real-time subscription to be established.
98
+ * Called internally by openChannel/createSpace before returning the channel.
99
+ * @internal
100
+ */
101
+ _waitForSubscription() {
102
+ return this._subscriptionReady;
103
+ }
104
+ // ===========================================================================
105
+ // Properties
106
+ // ===========================================================================
107
+ get id() {
108
+ return this._id;
109
+ }
110
+ get name() {
111
+ return this._name;
112
+ }
113
+ get role() {
114
+ return this._role;
115
+ }
116
+ get linkAccess() {
117
+ return this._linkAccess;
118
+ }
119
+ /** Current user's ID (for identifying own interactions) */
120
+ get userId() {
121
+ return this._userId;
122
+ }
123
+ /**
124
+ * Get the channel ID for this channel.
125
+ * Fixed at open time — cannot be changed.
126
+ */
127
+ get channelId() {
128
+ return this._channelId;
129
+ }
130
+ get isReadOnly() {
131
+ return this._role === 'viewer';
132
+ }
133
+ // ===========================================================================
134
+ // Conversation Access
135
+ // ===========================================================================
136
+ /**
137
+ * Get interactions for this channel's conversation.
138
+ */
139
+ getInteractions() {
140
+ return this._conversation?.interactions ?? [];
141
+ }
142
+ // ===========================================================================
143
+ // Channel Lifecycle
144
+ // ===========================================================================
145
+ /**
146
+ * Close this channel and clean up resources.
147
+ * Stops real-time subscription and unregisters from client.
148
+ */
149
+ close() {
150
+ this._closed = true;
151
+ this.subscriptionManager.destroy();
152
+ this.onCloseCallback(this._id);
153
+ // Clean up pending object collectors
154
+ this._objectResolvers.clear();
155
+ this._objectBuffer.clear();
156
+ this._pendingMutations.clear();
157
+ this.removeAllListeners();
158
+ }
159
+ // ===========================================================================
160
+ // Undo / Redo (Server-managed checkpoints)
161
+ // ===========================================================================
162
+ /**
163
+ * Create a checkpoint (seal current batch of changes).
164
+ * @returns The checkpoint ID
165
+ */
166
+ async checkpoint(label = 'Change') {
167
+ const result = await this.graphqlClient.checkpoint(this._id, label, this._channelId);
168
+ return result.checkpointId;
169
+ }
170
+ /**
171
+ * Check if undo is available.
172
+ */
173
+ async canUndo() {
174
+ const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
175
+ return status.canUndo;
176
+ }
177
+ /**
178
+ * Check if redo is available.
179
+ */
180
+ async canRedo() {
181
+ const status = await this.graphqlClient.checkpointStatus(this._id, this._channelId);
182
+ return status.canRedo;
183
+ }
184
+ /**
185
+ * Undo the most recent batch of changes.
186
+ * Reverses your most recent batch (sealed or open).
187
+ * Conflicting patches (modified by others) are silently skipped.
188
+ * @returns true if undo was performed
189
+ */
190
+ async undo() {
191
+ const result = await this.graphqlClient.undo(this._id, this._channelId);
192
+ // Server broadcasts space_changed, which triggers a reset event
193
+ return result.success;
194
+ }
195
+ /**
196
+ * Redo a previously undone batch of changes.
197
+ * @returns true if redo was performed
198
+ */
199
+ async redo() {
200
+ const result = await this.graphqlClient.redo(this._id, this._channelId);
201
+ // Server broadcasts space_changed, which triggers a reset event
202
+ return result.success;
203
+ }
204
+ /**
205
+ * Clear checkpoint history for this conversation.
206
+ */
207
+ async clearHistory() {
208
+ await this.graphqlClient.clearCheckpointHistory(this._id, this._channelId);
209
+ }
210
+ // ===========================================================================
211
+ // Object Operations
212
+ // ===========================================================================
213
+ /**
214
+ * Get an object's data by ID.
215
+ * Fetches from the server on each call.
216
+ */
217
+ async getObject(objectId) {
218
+ return this.graphqlClient.getObject(this._id, objectId);
219
+ }
220
+ /**
221
+ * Get an object's stat (audit information).
222
+ * Returns modification timestamp and author, or undefined if object not found.
223
+ */
224
+ stat(objectId) {
225
+ return this._objectStats.get(objectId);
226
+ }
227
+ /**
228
+ * Find objects using structured filters and/or natural language.
229
+ *
230
+ * `where` provides exact-match filtering — values must match literally (no placeholders or operators).
231
+ * `prompt` enables AI-powered semantic queries. When both are provided, `where` and `objectIds`
232
+ * constrain the data set before the AI sees it.
233
+ *
234
+ * @param options.where - Exact-match field filter (e.g. `{ type: 'article' }`). Constrains which objects the AI can see when combined with `prompt`.
235
+ * @param options.prompt - Natural language query. Triggers AI evaluation (uses credits).
236
+ * @param options.limit - Maximum number of results to return (applies to structured filtering only; the AI controls its own result size).
237
+ * @param options.objectIds - Scope search to specific object IDs. Constrains the candidate set in both structured and AI queries.
238
+ * @param options.order - Sort order by modifiedAt: `'asc'` or `'desc'` (default: `'desc'`). Only applies to structured filtering (no `prompt`).
239
+ * @param options.ephemeral - If true, the query won't be recorded in conversation history.
240
+ * @returns The matching objects and a descriptive message.
241
+ */
242
+ async findObjects(options) {
243
+ return this.graphqlClient.findObjects(this._id, options, this._channelId);
244
+ }
245
+ /**
246
+ * Get all object IDs (sync, from local cache).
247
+ * The list is loaded on open and kept current via SSE events.
248
+ * @param options.limit - Maximum number of IDs to return
249
+ * @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
250
+ */
251
+ getObjectIds(options) {
252
+ let ids = this._objectIds;
253
+ if (options?.order === 'asc') {
254
+ ids = [...ids].reverse();
255
+ }
256
+ if (options?.limit !== undefined) {
257
+ ids = ids.slice(0, options.limit);
258
+ }
259
+ return ids;
260
+ }
261
+ /**
262
+ * Create a new object with optional AI generation.
263
+ * @param options.data - Object data fields (any key-value pairs). Optionally include `id` to use a custom ID. Use {{placeholder}} for AI-generated content. Fields prefixed with _ are hidden from AI.
264
+ * @param options.ephemeral - If true, the operation won't be recorded in conversation history.
265
+ * @returns The created object (with AI-filled content) and message
266
+ */
267
+ async createObject(options) {
268
+ const { data, ephemeral } = options;
269
+ // Use data.id if provided (string), otherwise generate
270
+ const objectId = typeof data.id === 'string' ? data.id : generateEntityId();
271
+ // Validate ID format: alphanumeric, hyphens, underscores only
272
+ if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
273
+ throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
274
+ }
275
+ const dataWithId = { ...data, id: objectId };
276
+ // Emit optimistic event and track for dedup
277
+ this._pendingMutations.set(objectId, dataWithId);
278
+ this.emit('objectCreated', { objectId, object: dataWithId, source: 'local_user' });
279
+ try {
280
+ // Await mutation — server processes AI placeholders before responding.
281
+ // SSE events arrive during the await and are buffered via _deliverObject.
282
+ const { message } = await this.graphqlClient.createObject(this.id, dataWithId, this._channelId, ephemeral);
283
+ // Collect resolved object from buffer (or wait if not yet arrived)
284
+ const object = await this._collectObject(objectId);
285
+ return { object, message };
286
+ }
287
+ catch (error) {
288
+ this.logger.error('[RoolChannel] Failed to create object:', error);
289
+ this._pendingMutations.delete(objectId);
290
+ this._cancelCollector(objectId);
291
+ // Emit reset so UI can recover from the optimistic event
292
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
293
+ this.emit('reset', { source: 'system' });
294
+ throw error;
295
+ }
296
+ }
297
+ /**
298
+ * Update an existing object.
299
+ * @param objectId - The ID of the object to update
300
+ * @param options.data - Fields to add or update. Pass null or undefined to delete a field. Use {{placeholder}} for AI-generated content. Fields prefixed with _ are hidden from AI.
301
+ * @param options.prompt - AI prompt for content editing (optional).
302
+ * @param options.ephemeral - If true, the operation won't be recorded in conversation history.
303
+ * @returns The updated object (with AI-filled content) and message
304
+ */
305
+ async updateObject(objectId, options) {
306
+ const { data, ephemeral } = options;
307
+ // id is immutable after creation (but null/undefined means delete attempt, which we also reject)
308
+ if (data?.id !== undefined && data.id !== null) {
309
+ throw new Error('Cannot change id in updateObject. The id field is immutable after creation.');
310
+ }
311
+ if (data && ('id' in data)) {
312
+ throw new Error('Cannot delete id field. The id field is immutable after creation.');
313
+ }
314
+ // Normalize undefined to null (for JSON serialization) and build server data
315
+ let serverData;
316
+ if (data) {
317
+ serverData = {};
318
+ for (const [key, value] of Object.entries(data)) {
319
+ // Convert undefined to null for wire protocol
320
+ serverData[key] = value === undefined ? null : value;
321
+ }
322
+ }
323
+ // Emit optimistic event if we have data changes
324
+ if (data) {
325
+ // Build optimistic object (best effort — we may not have the current state)
326
+ const optimistic = { id: objectId, ...data };
327
+ this._pendingMutations.set(objectId, optimistic);
328
+ this.emit('objectUpdated', { objectId, object: optimistic, source: 'local_user' });
329
+ }
330
+ try {
331
+ const { message } = await this.graphqlClient.updateObject(this.id, objectId, this._channelId, serverData, options.prompt, ephemeral);
332
+ const object = await this._collectObject(objectId);
333
+ return { object, message };
334
+ }
335
+ catch (error) {
336
+ this.logger.error('[RoolChannel] Failed to update object:', error);
337
+ this._pendingMutations.delete(objectId);
338
+ this._cancelCollector(objectId);
339
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
340
+ this.emit('reset', { source: 'system' });
341
+ throw error;
342
+ }
343
+ }
344
+ /**
345
+ * Delete objects by IDs.
346
+ * Other objects that reference deleted objects via data fields will retain stale ref values.
347
+ */
348
+ async deleteObjects(objectIds) {
349
+ if (objectIds.length === 0)
350
+ return;
351
+ // Track for dedup and emit optimistic events
352
+ for (const objectId of objectIds) {
353
+ this._pendingMutations.set(objectId, null);
354
+ this.emit('objectDeleted', { objectId, source: 'local_user' });
355
+ }
356
+ try {
357
+ await this.graphqlClient.deleteObjects(this.id, objectIds, this._channelId);
358
+ }
359
+ catch (error) {
360
+ this.logger.error('[RoolChannel] Failed to delete objects:', error);
361
+ for (const objectId of objectIds) {
362
+ this._pendingMutations.delete(objectId);
363
+ }
364
+ this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
365
+ this.emit('reset', { source: 'system' });
366
+ throw error;
367
+ }
368
+ }
369
+ // ===========================================================================
370
+ // Collection Schema Operations
371
+ // ===========================================================================
372
+ /**
373
+ * Get the current schema for this space.
374
+ * Returns a map of collection names to their definitions.
375
+ */
376
+ getSchema() {
377
+ return this._schema;
378
+ }
379
+ /**
380
+ * Create a new collection schema.
381
+ * @param name - Collection name (must start with a letter, alphanumeric/hyphens/underscores only)
382
+ * @param fields - Field definitions for the collection
383
+ * @returns The created CollectionDef
384
+ */
385
+ async createCollection(name, fields) {
386
+ if (this._schema[name]) {
387
+ throw new Error(`Collection "${name}" already exists`);
388
+ }
389
+ // Optimistic local update
390
+ const optimisticDef = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
391
+ this._schema[name] = optimisticDef;
392
+ try {
393
+ return await this.graphqlClient.createCollection(this._id, name, fields, this._channelId);
394
+ }
395
+ catch (error) {
396
+ this.logger.error('[RoolChannel] Failed to create collection:', error);
397
+ delete this._schema[name];
398
+ throw error;
399
+ }
400
+ }
401
+ /**
402
+ * Alter an existing collection schema, replacing its field definitions.
403
+ * @param name - Name of the collection to alter
404
+ * @param fields - New field definitions (replaces all existing fields)
405
+ * @returns The updated CollectionDef
406
+ */
407
+ async alterCollection(name, fields) {
408
+ if (!this._schema[name]) {
409
+ throw new Error(`Collection "${name}" not found`);
410
+ }
411
+ const previous = this._schema[name];
412
+ // Optimistic local update
413
+ this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
414
+ try {
415
+ return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId);
416
+ }
417
+ catch (error) {
418
+ this.logger.error('[RoolChannel] Failed to alter collection:', error);
419
+ this._schema[name] = previous;
420
+ throw error;
421
+ }
422
+ }
423
+ /**
424
+ * Drop a collection schema.
425
+ * @param name - Name of the collection to drop
426
+ */
427
+ async dropCollection(name) {
428
+ if (!this._schema[name]) {
429
+ throw new Error(`Collection "${name}" not found`);
430
+ }
431
+ const previous = this._schema[name];
432
+ // Optimistic local update
433
+ delete this._schema[name];
434
+ try {
435
+ await this.graphqlClient.dropCollection(this._id, name, this._channelId);
436
+ }
437
+ catch (error) {
438
+ this.logger.error('[RoolChannel] Failed to drop collection:', error);
439
+ this._schema[name] = previous;
440
+ throw error;
441
+ }
442
+ }
443
+ // ===========================================================================
444
+ // System Instructions
445
+ // ===========================================================================
446
+ /**
447
+ * Get the system instruction for this channel's conversation.
448
+ * Returns undefined if no system instruction is set.
449
+ */
450
+ getSystemInstruction() {
451
+ return this._conversation?.systemInstruction;
452
+ }
453
+ /**
454
+ * Set the system instruction for this channel's conversation.
455
+ * Pass null to clear the instruction.
456
+ */
457
+ async setSystemInstruction(instruction) {
458
+ // Optimistic local update
459
+ if (!this._conversation) {
460
+ this._conversation = {
461
+ createdAt: Date.now(),
462
+ createdBy: this._userId,
463
+ interactions: [],
464
+ };
465
+ }
466
+ const previous = this._conversation;
467
+ if (instruction === null) {
468
+ const { systemInstruction: _, ...rest } = this._conversation;
469
+ this._conversation = rest;
470
+ }
471
+ else {
472
+ this._conversation = { ...this._conversation, systemInstruction: instruction };
473
+ }
474
+ // Emit event
475
+ this.emit('channelUpdated', {
476
+ channelId: this._channelId,
477
+ source: 'local_user',
478
+ });
479
+ // Call server
480
+ try {
481
+ await this.graphqlClient.setSystemInstruction(this.id, this._channelId, instruction);
482
+ }
483
+ catch (error) {
484
+ this.logger.error('[RoolChannel] Failed to set system instruction:', error);
485
+ this._conversation = previous;
486
+ throw error;
487
+ }
488
+ }
489
+ // ===========================================================================
490
+ // Metadata Operations
491
+ // ===========================================================================
492
+ /**
493
+ * Set a space-level metadata value.
494
+ * Metadata is stored in meta and hidden from AI operations.
495
+ */
496
+ setMetadata(key, value) {
497
+ this._meta[key] = value;
498
+ this.emit('metadataUpdated', { metadata: this._meta, source: 'local_user' });
499
+ // Fire-and-forget server call
500
+ this.graphqlClient.setSpaceMeta(this.id, this._meta, this._channelId)
501
+ .catch((error) => {
502
+ this.logger.error('[RoolChannel] Failed to set meta:', error);
503
+ });
504
+ }
505
+ /**
506
+ * Get a space-level metadata value.
507
+ */
508
+ getMetadata(key) {
509
+ return this._meta[key];
510
+ }
511
+ /**
512
+ * Get all space-level metadata.
513
+ */
514
+ getAllMetadata() {
515
+ return this._meta;
516
+ }
517
+ // ===========================================================================
518
+ // AI Operations
519
+ // ===========================================================================
520
+ /**
521
+ * Send a prompt to the AI agent for space manipulation.
522
+ * @returns The message from the AI and the list of objects that were created or modified
523
+ */
524
+ async prompt(prompt, options) {
525
+ // Upload attachments via media endpoint, then send URLs to the server
526
+ const { attachments, ...rest } = options ?? {};
527
+ let attachmentUrls;
528
+ if (attachments?.length) {
529
+ attachmentUrls = await Promise.all(attachments.map(file => this.mediaClient.upload(this._id, file)));
530
+ }
531
+ const result = await this.graphqlClient.prompt(this._id, prompt, this._channelId, { ...rest, attachmentUrls });
532
+ // Collect modified objects — they arrive via SSE events during/after the mutation.
533
+ // Try collecting from buffer first, then fetch any missing from server.
534
+ const objects = [];
535
+ const missing = [];
536
+ for (const id of result.modifiedObjectIds) {
537
+ const buffered = this._objectBuffer.get(id);
538
+ if (buffered) {
539
+ this._objectBuffer.delete(id);
540
+ objects.push(buffered);
541
+ }
542
+ else {
543
+ missing.push(id);
544
+ }
545
+ }
546
+ // Fetch any objects not yet received via SSE
547
+ if (missing.length > 0) {
548
+ const fetched = await Promise.all(missing.map(id => this.graphqlClient.getObject(this._id, id)));
549
+ for (const obj of fetched) {
550
+ if (obj)
551
+ objects.push(obj);
552
+ }
553
+ }
554
+ return {
555
+ message: result.message,
556
+ objects,
557
+ };
558
+ }
559
+ // ===========================================================================
560
+ // Channel Admin
561
+ // ===========================================================================
562
+ /**
563
+ * Rename this channel (conversation).
564
+ */
565
+ async rename(newName) {
566
+ await this.graphqlClient.renameChannel(this._id, this._channelId, newName);
567
+ }
568
+ // ===========================================================================
569
+ // Media Operations
570
+ // ===========================================================================
571
+ /**
572
+ * List all media files for this space.
573
+ */
574
+ async listMedia() {
575
+ return this.mediaClient.list(this._id);
576
+ }
577
+ /**
578
+ * Upload a file to this space. Returns the URL.
579
+ */
580
+ async uploadMedia(file) {
581
+ return this.mediaClient.upload(this._id, file);
582
+ }
583
+ /**
584
+ * Fetch any URL, returning headers and a blob() method (like fetch Response).
585
+ * Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
586
+ */
587
+ async fetchMedia(url) {
588
+ return this.mediaClient.fetch(this._id, url);
589
+ }
590
+ /**
591
+ * Delete a media file by URL.
592
+ */
593
+ async deleteMedia(url) {
594
+ return this.mediaClient.delete(this._id, url);
595
+ }
596
+ // ===========================================================================
597
+ // Object Collection (internal)
598
+ // ===========================================================================
599
+ /**
600
+ * Register a collector that resolves when the object arrives via SSE.
601
+ * If the object is already in the buffer (arrived before collector), resolves immediately.
602
+ * @internal
603
+ */
604
+ _collectObject(objectId) {
605
+ return new Promise((resolve, reject) => {
606
+ // Check buffer first — SSE event may have arrived before the HTTP response
607
+ const buffered = this._objectBuffer.get(objectId);
608
+ if (buffered) {
609
+ this._objectBuffer.delete(objectId);
610
+ resolve(buffered);
611
+ return;
612
+ }
613
+ const timer = setTimeout(() => {
614
+ this._objectResolvers.delete(objectId);
615
+ // Fallback: try to fetch from server
616
+ this.graphqlClient.getObject(this._id, objectId).then(obj => {
617
+ if (obj) {
618
+ resolve(obj);
619
+ }
620
+ else {
621
+ reject(new Error(`Timeout waiting for object ${objectId} from SSE`));
622
+ }
623
+ }).catch(reject);
624
+ }, OBJECT_COLLECT_TIMEOUT);
625
+ this._objectResolvers.set(objectId, (obj) => {
626
+ clearTimeout(timer);
627
+ resolve(obj);
628
+ });
629
+ });
630
+ }
631
+ /**
632
+ * Cancel a pending object collector (e.g., on mutation error).
633
+ * @internal
634
+ */
635
+ _cancelCollector(objectId) {
636
+ this._objectResolvers.delete(objectId);
637
+ this._objectBuffer.delete(objectId);
638
+ }
639
+ /**
640
+ * Deliver an object to a pending collector, or buffer it for later collection.
641
+ * @internal
642
+ */
643
+ _deliverObject(objectId, object) {
644
+ const resolver = this._objectResolvers.get(objectId);
645
+ if (resolver) {
646
+ resolver(object);
647
+ this._objectResolvers.delete(objectId);
648
+ }
649
+ else {
650
+ // Buffer for prompt() or late collectors
651
+ this._objectBuffer.set(objectId, object);
652
+ }
653
+ }
654
+ // ===========================================================================
655
+ // Event Handlers (internal - handles space subscription events)
656
+ // ===========================================================================
657
+ /**
658
+ * Handle a channel event from the subscription.
659
+ * @internal
660
+ */
661
+ handleChannelEvent(event) {
662
+ // Ignore events after close - the channel is being torn down
663
+ if (this._closed)
664
+ return;
665
+ const changeSource = event.source === 'agent' ? 'remote_agent' : 'remote_user';
666
+ switch (event.type) {
667
+ case 'object_created':
668
+ if (event.objectId && event.object) {
669
+ if (event.objectStat)
670
+ this._objectStats.set(event.objectId, event.objectStat);
671
+ this._handleObjectCreated(event.objectId, event.object, changeSource);
672
+ }
673
+ break;
674
+ case 'object_updated':
675
+ if (event.objectId && event.object) {
676
+ if (event.objectStat)
677
+ this._objectStats.set(event.objectId, event.objectStat);
678
+ this._handleObjectUpdated(event.objectId, event.object, changeSource);
679
+ }
680
+ break;
681
+ case 'object_deleted':
682
+ if (event.objectId) {
683
+ this._objectStats.delete(event.objectId);
684
+ this._handleObjectDeleted(event.objectId, changeSource);
685
+ }
686
+ break;
687
+ case 'schema_updated':
688
+ if (event.schema) {
689
+ this._schema = event.schema;
690
+ }
691
+ break;
692
+ case 'metadata_updated':
693
+ if (event.metadata) {
694
+ this._meta = event.metadata;
695
+ this.emit('metadataUpdated', { metadata: this._meta, source: changeSource });
696
+ }
697
+ break;
698
+ case 'channel_updated':
699
+ // Only update if it's our channel
700
+ if (event.channelId === this._channelId && event.channel) {
701
+ this._conversation = event.channel;
702
+ this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
703
+ }
704
+ break;
705
+ case 'space_changed':
706
+ // Full reload needed (undo/redo, bulk operations)
707
+ void this.graphqlClient.openChannel(this._id, this._channelId).then((result) => {
708
+ if (this._closed)
709
+ return;
710
+ this._meta = result.meta;
711
+ this._schema = result.schema;
712
+ this._conversation = result.channel;
713
+ this._objectIds = result.objectIds;
714
+ this._objectStats = new Map(Object.entries(result.objectStats));
715
+ this.emit('reset', { source: changeSource });
716
+ });
717
+ break;
718
+ }
719
+ }
720
+ /**
721
+ * Handle an object_created SSE event.
722
+ * Deduplicates against optimistic local creates.
723
+ * @internal
724
+ */
725
+ _handleObjectCreated(objectId, object, source) {
726
+ // Deliver to any pending collector (for mutation return values)
727
+ this._deliverObject(objectId, object);
728
+ // Maintain local ID list — prepend (most recently modified first)
729
+ this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
730
+ const pending = this._pendingMutations.get(objectId);
731
+ if (pending !== undefined) {
732
+ // This is our own mutation echoed back
733
+ this._pendingMutations.delete(objectId);
734
+ if (pending !== null) {
735
+ // It was a create — already emitted objectCreated optimistically.
736
+ // Emit objectUpdated only if AI resolved placeholders (data changed).
737
+ if (JSON.stringify(pending) !== JSON.stringify(object)) {
738
+ this.emit('objectUpdated', { objectId, object, source });
739
+ }
740
+ }
741
+ }
742
+ else {
743
+ // Remote event — emit normally
744
+ this.emit('objectCreated', { objectId, object, source });
745
+ }
746
+ }
747
+ /**
748
+ * Handle an object_updated SSE event.
749
+ * Deduplicates against optimistic local updates.
750
+ * @internal
751
+ */
752
+ _handleObjectUpdated(objectId, object, source) {
753
+ // Deliver to any pending collector
754
+ this._deliverObject(objectId, object);
755
+ // Maintain local ID list — move to front (most recently modified)
756
+ this._objectIds = [objectId, ...this._objectIds.filter(id => id !== objectId)];
757
+ const pending = this._pendingMutations.get(objectId);
758
+ if (pending !== undefined) {
759
+ // This is our own mutation echoed back
760
+ this._pendingMutations.delete(objectId);
761
+ if (pending !== null) {
762
+ // Already emitted objectUpdated optimistically.
763
+ // Emit again only if data changed (AI resolved placeholders).
764
+ if (JSON.stringify(pending) !== JSON.stringify(object)) {
765
+ this.emit('objectUpdated', { objectId, object, source });
766
+ }
767
+ }
768
+ }
769
+ else {
770
+ // Remote event
771
+ this.emit('objectUpdated', { objectId, object, source });
772
+ }
773
+ }
774
+ /**
775
+ * Handle an object_deleted SSE event.
776
+ * Deduplicates against optimistic local deletes.
777
+ * @internal
778
+ */
779
+ _handleObjectDeleted(objectId, source) {
780
+ // Remove from local ID list
781
+ this._objectIds = this._objectIds.filter(id => id !== objectId);
782
+ const pending = this._pendingMutations.get(objectId);
783
+ if (pending !== undefined) {
784
+ // This is our own delete echoed back — already emitted
785
+ this._pendingMutations.delete(objectId);
786
+ }
787
+ else {
788
+ // Remote event
789
+ this.emit('objectDeleted', { objectId, source });
790
+ }
791
+ }
792
+ }
793
+ //# sourceMappingURL=channel.js.map