@rool-dev/client 0.3.1 → 0.4.0-dev.22f8ef0
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 +189 -90
- package/dist/auth-browser.d.ts.map +1 -1
- package/dist/auth-browser.js +45 -27
- package/dist/auth-browser.js.map +1 -1
- package/dist/client.d.ts +29 -26
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +90 -88
- package/dist/client.js.map +1 -1
- package/dist/graphql.d.ts +16 -13
- package/dist/graphql.d.ts.map +1 -1
- package/dist/graphql.js +113 -93
- package/dist/graphql.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/media.d.ts +7 -7
- package/dist/media.js +14 -14
- package/dist/space.d.ts +256 -0
- package/dist/space.d.ts.map +1 -0
- package/dist/space.js +730 -0
- package/dist/space.js.map +1 -0
- package/dist/subscription.d.ts +2 -2
- package/dist/subscription.d.ts.map +1 -1
- package/dist/subscription.js +14 -17
- package/dist/subscription.js.map +1 -1
- package/dist/types.d.ts +101 -62
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/package.json +1 -1
- package/dist/graph.d.ts +0 -240
- package/dist/graph.d.ts.map +0 -1
- package/dist/graph.js +0 -581
- package/dist/graph.js.map +0 -1
package/dist/space.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { immutableJSONPatch } from 'immutable-json-patch';
|
|
2
|
+
import { EventEmitter } from './event-emitter.js';
|
|
3
|
+
const MAX_UNDO_STACK_SIZE = 50;
|
|
4
|
+
// 6-character alphanumeric ID (62^6 = 56.8 billion possible values)
|
|
5
|
+
const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
6
|
+
export function generateEntityId() {
|
|
7
|
+
let result = '';
|
|
8
|
+
for (let i = 0; i < 6; i++) {
|
|
9
|
+
result += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
|
|
10
|
+
}
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* First-class Space object.
|
|
15
|
+
*
|
|
16
|
+
* Features:
|
|
17
|
+
* - High-level object/link operations
|
|
18
|
+
* - Built-in undo/redo with checkpoints
|
|
19
|
+
* - Metadata management
|
|
20
|
+
* - Event emission for state changes
|
|
21
|
+
* - Real-time subscription support
|
|
22
|
+
*/
|
|
23
|
+
export class RoolSpace extends EventEmitter {
|
|
24
|
+
_id;
|
|
25
|
+
_name;
|
|
26
|
+
_role;
|
|
27
|
+
_data;
|
|
28
|
+
graphqlClient;
|
|
29
|
+
mediaClient;
|
|
30
|
+
onRegisterForEvents;
|
|
31
|
+
onUnregisterFromEvents;
|
|
32
|
+
// Undo/redo stacks
|
|
33
|
+
undoStack = [];
|
|
34
|
+
redoStack = [];
|
|
35
|
+
// Subscription state
|
|
36
|
+
_isSubscribed = false;
|
|
37
|
+
constructor(config) {
|
|
38
|
+
super();
|
|
39
|
+
this._id = config.id;
|
|
40
|
+
this._name = config.name;
|
|
41
|
+
this._role = config.role;
|
|
42
|
+
this._data = config.initialData;
|
|
43
|
+
this.graphqlClient = config.graphqlClient;
|
|
44
|
+
this.mediaClient = config.mediaClient;
|
|
45
|
+
this.onRegisterForEvents = config.onRegisterForEvents;
|
|
46
|
+
this.onUnregisterFromEvents = config.onUnregisterFromEvents;
|
|
47
|
+
}
|
|
48
|
+
// ===========================================================================
|
|
49
|
+
// Properties
|
|
50
|
+
// ===========================================================================
|
|
51
|
+
get id() {
|
|
52
|
+
return this._id;
|
|
53
|
+
}
|
|
54
|
+
get name() {
|
|
55
|
+
return this._name;
|
|
56
|
+
}
|
|
57
|
+
get role() {
|
|
58
|
+
return this._role;
|
|
59
|
+
}
|
|
60
|
+
get isReadOnly() {
|
|
61
|
+
return this._role === 'viewer';
|
|
62
|
+
}
|
|
63
|
+
get isSubscribed() {
|
|
64
|
+
return this._isSubscribed;
|
|
65
|
+
}
|
|
66
|
+
// ===========================================================================
|
|
67
|
+
// Space Lifecycle
|
|
68
|
+
// ===========================================================================
|
|
69
|
+
/**
|
|
70
|
+
* Rename this space.
|
|
71
|
+
*/
|
|
72
|
+
async rename(newName) {
|
|
73
|
+
const oldName = this._name;
|
|
74
|
+
this._name = newName;
|
|
75
|
+
try {
|
|
76
|
+
await this.graphqlClient.renameSpace(this._id, newName);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
this._name = oldName;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to real-time updates for this space.
|
|
85
|
+
* Registers with the client for event routing.
|
|
86
|
+
*/
|
|
87
|
+
subscribe() {
|
|
88
|
+
if (this._isSubscribed)
|
|
89
|
+
return;
|
|
90
|
+
this._isSubscribed = true;
|
|
91
|
+
this.onRegisterForEvents(this._id, this);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Unsubscribe from real-time updates.
|
|
95
|
+
*/
|
|
96
|
+
unsubscribe() {
|
|
97
|
+
if (!this._isSubscribed)
|
|
98
|
+
return;
|
|
99
|
+
this._isSubscribed = false;
|
|
100
|
+
this.onUnregisterFromEvents(this._id);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Close this space and clean up resources.
|
|
104
|
+
*/
|
|
105
|
+
close() {
|
|
106
|
+
this.unsubscribe();
|
|
107
|
+
this.undoStack = [];
|
|
108
|
+
this.redoStack = [];
|
|
109
|
+
this.removeAllListeners();
|
|
110
|
+
}
|
|
111
|
+
// ===========================================================================
|
|
112
|
+
// Undo / Redo
|
|
113
|
+
// ===========================================================================
|
|
114
|
+
/**
|
|
115
|
+
* Create a checkpoint for undo.
|
|
116
|
+
* Call this before a user action to capture the current state.
|
|
117
|
+
*/
|
|
118
|
+
checkpoint(label = 'Change') {
|
|
119
|
+
const entry = {
|
|
120
|
+
timestamp: Date.now(),
|
|
121
|
+
label,
|
|
122
|
+
data: JSON.parse(JSON.stringify(this._data)),
|
|
123
|
+
};
|
|
124
|
+
this.undoStack.push(entry);
|
|
125
|
+
// Limit stack size
|
|
126
|
+
if (this.undoStack.length > MAX_UNDO_STACK_SIZE) {
|
|
127
|
+
this.undoStack.shift();
|
|
128
|
+
}
|
|
129
|
+
// Clear redo stack (new action invalidates redo)
|
|
130
|
+
this.redoStack = [];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Check if undo is available.
|
|
134
|
+
*/
|
|
135
|
+
canUndo() {
|
|
136
|
+
return this.undoStack.length > 0;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Check if redo is available.
|
|
140
|
+
*/
|
|
141
|
+
canRedo() {
|
|
142
|
+
return this.redoStack.length > 0;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Undo to the previous checkpoint.
|
|
146
|
+
* @returns true if undo was performed
|
|
147
|
+
*/
|
|
148
|
+
async undo() {
|
|
149
|
+
if (!this.canUndo())
|
|
150
|
+
return false;
|
|
151
|
+
// Save current state to redo stack
|
|
152
|
+
const currentEntry = {
|
|
153
|
+
timestamp: Date.now(),
|
|
154
|
+
label: 'Redo point',
|
|
155
|
+
data: JSON.parse(JSON.stringify(this._data)),
|
|
156
|
+
};
|
|
157
|
+
this.redoStack.push(currentEntry);
|
|
158
|
+
// Restore previous state
|
|
159
|
+
const previousEntry = this.undoStack.pop();
|
|
160
|
+
this._data = previousEntry.data;
|
|
161
|
+
// Sync to server - resync on failure
|
|
162
|
+
try {
|
|
163
|
+
await this.graphqlClient.setSpace(this._id, this._data);
|
|
164
|
+
this.emit('reset', { source: 'local_user' });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
console.error('[Space] Failed to sync undo to server:', error);
|
|
168
|
+
await this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Redo a previously undone action.
|
|
174
|
+
* @returns true if redo was performed
|
|
175
|
+
*/
|
|
176
|
+
async redo() {
|
|
177
|
+
if (!this.canRedo())
|
|
178
|
+
return false;
|
|
179
|
+
// Save current state to undo stack
|
|
180
|
+
const currentEntry = {
|
|
181
|
+
timestamp: Date.now(),
|
|
182
|
+
label: 'Undo point',
|
|
183
|
+
data: JSON.parse(JSON.stringify(this._data)),
|
|
184
|
+
};
|
|
185
|
+
this.undoStack.push(currentEntry);
|
|
186
|
+
// Restore next state
|
|
187
|
+
const nextEntry = this.redoStack.pop();
|
|
188
|
+
this._data = nextEntry.data;
|
|
189
|
+
// Sync to server - resync on failure
|
|
190
|
+
try {
|
|
191
|
+
await this.graphqlClient.setSpace(this._id, this._data);
|
|
192
|
+
this.emit('reset', { source: 'local_user' });
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
console.error('[Space] Failed to sync redo to server:', error);
|
|
196
|
+
await this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
197
|
+
}
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Clear undo/redo history.
|
|
202
|
+
* Called when external changes invalidate local history.
|
|
203
|
+
*/
|
|
204
|
+
clearHistory() {
|
|
205
|
+
this.undoStack = [];
|
|
206
|
+
this.redoStack = [];
|
|
207
|
+
}
|
|
208
|
+
// ===========================================================================
|
|
209
|
+
// Object Operations
|
|
210
|
+
// ===========================================================================
|
|
211
|
+
/**
|
|
212
|
+
* Get an object's data by ID.
|
|
213
|
+
* Returns just the data portion (RoolObject), not the full entry with meta/links.
|
|
214
|
+
* @throws Error if object not found
|
|
215
|
+
*/
|
|
216
|
+
getObject(objectId) {
|
|
217
|
+
const entry = this._data.objects[objectId];
|
|
218
|
+
if (!entry) {
|
|
219
|
+
throw new Error(`Object ${objectId} not found`);
|
|
220
|
+
}
|
|
221
|
+
return entry.data;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Get an object's data by ID, or undefined if not found.
|
|
225
|
+
*/
|
|
226
|
+
getObjectOrUndefined(objectId) {
|
|
227
|
+
return this._data.objects[objectId]?.data;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Get an object's metadata (position, UI state, etc).
|
|
231
|
+
*/
|
|
232
|
+
getObjectMeta(objectId) {
|
|
233
|
+
const entry = this._data.objects[objectId];
|
|
234
|
+
if (!entry) {
|
|
235
|
+
throw new Error(`Object ${objectId} not found`);
|
|
236
|
+
}
|
|
237
|
+
return entry.meta;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Query objects by field values.
|
|
241
|
+
* Returns all objects where all specified fields match the given values.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* space.queryObjects({ type: 'article' })
|
|
245
|
+
* space.queryObjects({ type: 'task', status: 'done' })
|
|
246
|
+
*/
|
|
247
|
+
queryObjects(where) {
|
|
248
|
+
return Object.values(this._data.objects)
|
|
249
|
+
.filter(entry => {
|
|
250
|
+
return Object.entries(where).every(([key, value]) => entry.data[key] === value);
|
|
251
|
+
})
|
|
252
|
+
.map(entry => entry.data);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Get all object IDs.
|
|
256
|
+
*/
|
|
257
|
+
getObjectIds() {
|
|
258
|
+
return Object.keys(this._data.objects);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Create a new object with optional AI generation.
|
|
262
|
+
* @param options.data - Object data fields (any key-value pairs). Use {{placeholder}} for AI-generated content.
|
|
263
|
+
* @param options.meta - Client-private metadata (optional). Hidden from AI operations.
|
|
264
|
+
* @param options.prompt - AI prompt for content generation (optional).
|
|
265
|
+
* @returns The generated object ID
|
|
266
|
+
*/
|
|
267
|
+
async createObject(options) {
|
|
268
|
+
const objectId = generateEntityId();
|
|
269
|
+
const { data, meta, prompt } = options;
|
|
270
|
+
// Build the entry for local state
|
|
271
|
+
const entry = {
|
|
272
|
+
meta: meta ?? {},
|
|
273
|
+
links: {},
|
|
274
|
+
data,
|
|
275
|
+
};
|
|
276
|
+
// Update local state immediately (optimistic)
|
|
277
|
+
this._data.objects[objectId] = entry;
|
|
278
|
+
this.emit('objectCreated', { objectId, object: entry.data, source: 'local_user' });
|
|
279
|
+
// Await server call
|
|
280
|
+
try {
|
|
281
|
+
const message = await this.graphqlClient.createObject(this.id, objectId, data, meta, prompt);
|
|
282
|
+
return { id: objectId, message };
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
console.error('[Space] Failed to create object:', error);
|
|
286
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Update an existing object.
|
|
292
|
+
* @param objectId - The ID of the object to update
|
|
293
|
+
* @param options.data - Fields to add or update. Use {{placeholder}} for AI-generated content.
|
|
294
|
+
* @param options.meta - Client-private metadata to merge. Hidden from AI operations.
|
|
295
|
+
* @param options.prompt - AI prompt for content editing (optional).
|
|
296
|
+
*/
|
|
297
|
+
async updateObject(objectId, options) {
|
|
298
|
+
const entry = this._data.objects[objectId];
|
|
299
|
+
if (!entry) {
|
|
300
|
+
throw new Error(`Object ${objectId} not found for update`);
|
|
301
|
+
}
|
|
302
|
+
const { data, meta } = options;
|
|
303
|
+
// Build local updates
|
|
304
|
+
if (data) {
|
|
305
|
+
Object.assign(entry.data, data);
|
|
306
|
+
}
|
|
307
|
+
if (meta) {
|
|
308
|
+
entry.meta = { ...entry.meta, ...meta };
|
|
309
|
+
}
|
|
310
|
+
// Emit semantic event with updated object
|
|
311
|
+
if (data || meta) {
|
|
312
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source: 'local_user' });
|
|
313
|
+
}
|
|
314
|
+
// Await server call
|
|
315
|
+
try {
|
|
316
|
+
return await this.graphqlClient.updateObject(this.id, objectId, data, meta, options.prompt);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
console.error('[Space] Failed to update object:', error);
|
|
320
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Delete objects by IDs.
|
|
326
|
+
* Outbound links are automatically deleted with the object.
|
|
327
|
+
* Inbound links become orphans (tolerated).
|
|
328
|
+
*/
|
|
329
|
+
async deleteObjects(objectIds) {
|
|
330
|
+
if (objectIds.length === 0)
|
|
331
|
+
return;
|
|
332
|
+
const deletedObjectIds = [];
|
|
333
|
+
// Collect links that will be orphaned (for events)
|
|
334
|
+
const deletedLinks = [];
|
|
335
|
+
for (const objectId of objectIds) {
|
|
336
|
+
const entry = this._data.objects[objectId];
|
|
337
|
+
if (entry) {
|
|
338
|
+
// Collect outbound links for deletion events
|
|
339
|
+
for (const [linkType, targets] of Object.entries(entry.links)) {
|
|
340
|
+
for (const targetId of Object.keys(targets)) {
|
|
341
|
+
deletedLinks.push({ sourceId: objectId, targetId, linkType });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Remove objects (local state)
|
|
347
|
+
for (const objectId of objectIds) {
|
|
348
|
+
if (this._data.objects[objectId]) {
|
|
349
|
+
delete this._data.objects[objectId];
|
|
350
|
+
deletedObjectIds.push(objectId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Emit semantic events
|
|
354
|
+
for (const link of deletedLinks) {
|
|
355
|
+
this.emit('unlinked', { ...link, source: 'local_user' });
|
|
356
|
+
}
|
|
357
|
+
for (const objectId of deletedObjectIds) {
|
|
358
|
+
this.emit('objectDeleted', { objectId, source: 'local_user' });
|
|
359
|
+
}
|
|
360
|
+
// Await server call
|
|
361
|
+
try {
|
|
362
|
+
await this.graphqlClient.deleteObjects(this.id, objectIds);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
console.error('[Space] Failed to delete objects:', error);
|
|
366
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// ===========================================================================
|
|
371
|
+
// Link Operations
|
|
372
|
+
// ===========================================================================
|
|
373
|
+
/**
|
|
374
|
+
* Create a link between objects.
|
|
375
|
+
* Links are stored on the source object.
|
|
376
|
+
*/
|
|
377
|
+
async link(sourceId, targetId, linkType) {
|
|
378
|
+
const entry = this._data.objects[sourceId];
|
|
379
|
+
if (!entry) {
|
|
380
|
+
throw new Error(`Source object ${sourceId} not found`);
|
|
381
|
+
}
|
|
382
|
+
// Update local state immediately
|
|
383
|
+
if (!entry.links[linkType]) {
|
|
384
|
+
entry.links[linkType] = {};
|
|
385
|
+
}
|
|
386
|
+
entry.links[linkType][targetId] = {};
|
|
387
|
+
const linkData = entry.links[linkType][targetId];
|
|
388
|
+
this.emit('linked', { sourceId, targetId, linkType, linkData, source: 'local_user' });
|
|
389
|
+
// Await server call
|
|
390
|
+
try {
|
|
391
|
+
await this.graphqlClient.link(this.id, sourceId, targetId, linkType);
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
console.error('[Space] Failed to create link:', error);
|
|
395
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Remove a link between two objects.
|
|
401
|
+
* @param linkType - Optional: if provided, only removes that type; otherwise removes all links between the objects
|
|
402
|
+
* @returns true if any links were removed
|
|
403
|
+
*/
|
|
404
|
+
async unlink(sourceId, targetId, linkType) {
|
|
405
|
+
const entry = this._data.objects[sourceId];
|
|
406
|
+
if (!entry) {
|
|
407
|
+
throw new Error(`Source object ${sourceId} not found`);
|
|
408
|
+
}
|
|
409
|
+
const deletedLinks = [];
|
|
410
|
+
// Update local state
|
|
411
|
+
if (linkType) {
|
|
412
|
+
// Remove specific link type
|
|
413
|
+
if (entry.links[linkType]?.[targetId] !== undefined) {
|
|
414
|
+
delete entry.links[linkType][targetId];
|
|
415
|
+
deletedLinks.push({ linkType });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
// Remove all links from source to target
|
|
420
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
421
|
+
if (targets[targetId] !== undefined) {
|
|
422
|
+
delete targets[targetId];
|
|
423
|
+
deletedLinks.push({ linkType: type });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Emit semantic events
|
|
428
|
+
for (const link of deletedLinks) {
|
|
429
|
+
this.emit('unlinked', { sourceId, targetId, linkType: link.linkType, source: 'local_user' });
|
|
430
|
+
}
|
|
431
|
+
// Await server call
|
|
432
|
+
try {
|
|
433
|
+
await this.graphqlClient.unlink(this.id, sourceId, targetId);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
console.error('[Space] Failed to remove link:', error);
|
|
437
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
return deletedLinks.length > 0;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Get parent object IDs (objects that have links pointing TO this object).
|
|
444
|
+
* @param linkType - Optional filter by link type
|
|
445
|
+
*/
|
|
446
|
+
getParents(objectId, linkType) {
|
|
447
|
+
const parents = [];
|
|
448
|
+
for (const [entryId, entry] of Object.entries(this._data.objects)) {
|
|
449
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
450
|
+
if ((!linkType || type === linkType) && objectId in targets) {
|
|
451
|
+
parents.push(entryId);
|
|
452
|
+
break; // Found a link, move to next object
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return parents;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Get child object IDs (objects that this object has links pointing TO).
|
|
460
|
+
* Filters out orphan targets (targets that don't exist).
|
|
461
|
+
* @param linkType - Optional filter by link type
|
|
462
|
+
*/
|
|
463
|
+
getChildren(objectId, linkType) {
|
|
464
|
+
const entry = this._data.objects[objectId];
|
|
465
|
+
if (!entry)
|
|
466
|
+
return [];
|
|
467
|
+
const children = [];
|
|
468
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
469
|
+
if (!linkType || type === linkType) {
|
|
470
|
+
for (const targetId of Object.keys(targets)) {
|
|
471
|
+
// Filter orphans - only return existing targets
|
|
472
|
+
if (this._data.objects[targetId]) {
|
|
473
|
+
children.push(targetId);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return children;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Get all child object IDs including orphans (targets that may not exist).
|
|
482
|
+
* @param linkType - Optional filter by link type
|
|
483
|
+
*/
|
|
484
|
+
getChildrenIncludingOrphans(objectId, linkType) {
|
|
485
|
+
const entry = this._data.objects[objectId];
|
|
486
|
+
if (!entry)
|
|
487
|
+
return [];
|
|
488
|
+
const children = [];
|
|
489
|
+
for (const [type, targets] of Object.entries(entry.links)) {
|
|
490
|
+
if (!linkType || type === linkType) {
|
|
491
|
+
children.push(...Object.keys(targets));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return children;
|
|
495
|
+
}
|
|
496
|
+
// ===========================================================================
|
|
497
|
+
// Metadata Operations
|
|
498
|
+
// ===========================================================================
|
|
499
|
+
/**
|
|
500
|
+
* Set a space-level metadata value.
|
|
501
|
+
* Metadata is stored in meta and hidden from AI operations.
|
|
502
|
+
*/
|
|
503
|
+
setMetadata(key, value) {
|
|
504
|
+
if (!this._data.meta) {
|
|
505
|
+
this._data.meta = {};
|
|
506
|
+
}
|
|
507
|
+
this._data.meta[key] = value;
|
|
508
|
+
this.emit('metaUpdated', { meta: this._data.meta, source: 'local_user' });
|
|
509
|
+
// Fire-and-forget server call - errors trigger resync
|
|
510
|
+
this.graphqlClient.setSpaceMeta(this.id, this._data.meta)
|
|
511
|
+
.catch((error) => {
|
|
512
|
+
console.error('[Space] Failed to set graph meta:', error);
|
|
513
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Get a space-level metadata value.
|
|
518
|
+
*/
|
|
519
|
+
getMetadata(key) {
|
|
520
|
+
return this._data.meta?.[key];
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Get all space-level metadata.
|
|
524
|
+
*/
|
|
525
|
+
getAllMetadata() {
|
|
526
|
+
return this._data.meta ?? {};
|
|
527
|
+
}
|
|
528
|
+
// ===========================================================================
|
|
529
|
+
// AI Operations
|
|
530
|
+
// ===========================================================================
|
|
531
|
+
/**
|
|
532
|
+
* Send a prompt to the AI agent for space manipulation.
|
|
533
|
+
*/
|
|
534
|
+
async prompt(prompt, options) {
|
|
535
|
+
return this.graphqlClient.prompt(this._id, prompt, options);
|
|
536
|
+
}
|
|
537
|
+
// ===========================================================================
|
|
538
|
+
// Collaboration
|
|
539
|
+
// ===========================================================================
|
|
540
|
+
/**
|
|
541
|
+
* List users with access to this space.
|
|
542
|
+
*/
|
|
543
|
+
async listUsers() {
|
|
544
|
+
return this.graphqlClient.listSpaceUsers(this._id);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Add a user to this space with specified role.
|
|
548
|
+
*/
|
|
549
|
+
async addUser(userId, role) {
|
|
550
|
+
return this.graphqlClient.addSpaceUser(this._id, userId, role);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Remove a user from this space.
|
|
554
|
+
*/
|
|
555
|
+
async removeUser(userId) {
|
|
556
|
+
return this.graphqlClient.removeSpaceUser(this._id, userId);
|
|
557
|
+
}
|
|
558
|
+
// ===========================================================================
|
|
559
|
+
// Media Operations
|
|
560
|
+
// ===========================================================================
|
|
561
|
+
/**
|
|
562
|
+
* List all media files for this space.
|
|
563
|
+
*/
|
|
564
|
+
async listMedia() {
|
|
565
|
+
return this.mediaClient.list(this._id);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Upload a file to this space.
|
|
569
|
+
*/
|
|
570
|
+
async uploadMedia(file) {
|
|
571
|
+
return this.mediaClient.upload(this._id, file);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Get the URL for a media file.
|
|
575
|
+
*/
|
|
576
|
+
getMediaUrl(uuid) {
|
|
577
|
+
return this.mediaClient.getUrl(this._id, uuid);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Download a media file as a Blob.
|
|
581
|
+
*/
|
|
582
|
+
async downloadMedia(uuid) {
|
|
583
|
+
return this.mediaClient.download(this._id, uuid);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Delete a media file.
|
|
587
|
+
*/
|
|
588
|
+
async deleteMedia(uuid) {
|
|
589
|
+
return this.mediaClient.delete(this._id, uuid);
|
|
590
|
+
}
|
|
591
|
+
// ===========================================================================
|
|
592
|
+
// Low-level Operations
|
|
593
|
+
// ===========================================================================
|
|
594
|
+
/**
|
|
595
|
+
* Get the full space data.
|
|
596
|
+
* Use sparingly - prefer specific operations.
|
|
597
|
+
*/
|
|
598
|
+
getData() {
|
|
599
|
+
return this._data;
|
|
600
|
+
}
|
|
601
|
+
// ===========================================================================
|
|
602
|
+
// Event Handlers (called by RoolClient for routing)
|
|
603
|
+
// ===========================================================================
|
|
604
|
+
/**
|
|
605
|
+
* Handle a patch event from another client.
|
|
606
|
+
* @internal
|
|
607
|
+
*/
|
|
608
|
+
handleRemotePatch(patch, source = 'user') {
|
|
609
|
+
try {
|
|
610
|
+
this._data = immutableJSONPatch(this._data, patch);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
console.error('[Space] Failed to apply remote patch:', error);
|
|
614
|
+
// Force resync on patch error
|
|
615
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
// Parse patch operations and emit semantic events
|
|
619
|
+
const changeSource = source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
620
|
+
this.emitSemanticEventsFromPatch(patch, changeSource);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Parse JSON patch operations and emit semantic events.
|
|
624
|
+
* @internal
|
|
625
|
+
*/
|
|
626
|
+
emitSemanticEventsFromPatch(patch, source) {
|
|
627
|
+
// Track which objects have been updated (to avoid duplicate events)
|
|
628
|
+
const updatedObjects = new Set();
|
|
629
|
+
for (const op of patch) {
|
|
630
|
+
const { path } = op;
|
|
631
|
+
// Object operations: /objects/{objectId}/...
|
|
632
|
+
if (path.startsWith('/objects/')) {
|
|
633
|
+
const parts = path.split('/');
|
|
634
|
+
const objectId = parts[2];
|
|
635
|
+
if (parts.length === 3) {
|
|
636
|
+
// /objects/{objectId} - full object add or remove
|
|
637
|
+
if (op.op === 'add') {
|
|
638
|
+
const entry = this._data.objects[objectId];
|
|
639
|
+
if (entry) {
|
|
640
|
+
this.emit('objectCreated', { objectId, object: entry.data, source });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
else if (op.op === 'remove') {
|
|
644
|
+
this.emit('objectDeleted', { objectId, source });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
else if (parts[3] === 'data') {
|
|
648
|
+
// /objects/{objectId}/data/... - data field update
|
|
649
|
+
if (!updatedObjects.has(objectId)) {
|
|
650
|
+
const entry = this._data.objects[objectId];
|
|
651
|
+
if (entry) {
|
|
652
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source });
|
|
653
|
+
updatedObjects.add(objectId);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
else if (parts[3] === 'links') {
|
|
658
|
+
// /objects/{objectId}/links/{type}/{targetId}
|
|
659
|
+
if (parts.length >= 6) {
|
|
660
|
+
const linkType = parts[4];
|
|
661
|
+
const targetId = parts[5];
|
|
662
|
+
if (op.op === 'add') {
|
|
663
|
+
const linkData = this._data.objects[objectId]?.links[linkType]?.[targetId] ?? {};
|
|
664
|
+
this.emit('linked', { sourceId: objectId, targetId, linkType, linkData, source });
|
|
665
|
+
}
|
|
666
|
+
else if (op.op === 'remove') {
|
|
667
|
+
this.emit('unlinked', { sourceId: objectId, targetId, linkType, source });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
else if (parts.length === 5 && op.op === 'add') {
|
|
671
|
+
// /objects/{objectId}/links/{type} - new link type object added
|
|
672
|
+
const linkType = parts[4];
|
|
673
|
+
const targets = this._data.objects[objectId]?.links[linkType] ?? {};
|
|
674
|
+
for (const [targetId, linkData] of Object.entries(targets)) {
|
|
675
|
+
this.emit('linked', { sourceId: objectId, targetId, linkType, linkData: linkData, source });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
else if (parts[3] === 'meta') {
|
|
680
|
+
// /objects/{objectId}/meta/... - object meta update
|
|
681
|
+
// Could emit an objectMeta event if needed, but for now treat as object update
|
|
682
|
+
if (!updatedObjects.has(objectId)) {
|
|
683
|
+
const entry = this._data.objects[objectId];
|
|
684
|
+
if (entry) {
|
|
685
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source });
|
|
686
|
+
updatedObjects.add(objectId);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
else if (path === '/meta' || path.startsWith('/meta/')) {
|
|
692
|
+
this.emit('metaUpdated', { meta: this._data.meta, source });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Handle a full reload from server.
|
|
698
|
+
* @internal
|
|
699
|
+
*/
|
|
700
|
+
handleRemoteChange(newData) {
|
|
701
|
+
this._data = newData;
|
|
702
|
+
this.emit('reset', { source: 'remote_user' });
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Update the name from external source.
|
|
706
|
+
* @internal
|
|
707
|
+
*/
|
|
708
|
+
handleRemoteRename(newName) {
|
|
709
|
+
this._name = newName;
|
|
710
|
+
}
|
|
711
|
+
// ===========================================================================
|
|
712
|
+
// Private Methods
|
|
713
|
+
// ===========================================================================
|
|
714
|
+
async resyncFromServer(originalError) {
|
|
715
|
+
console.warn('[Space] Resyncing from server after sync failure');
|
|
716
|
+
try {
|
|
717
|
+
const serverData = await this.graphqlClient.getSpace(this._id);
|
|
718
|
+
this._data = serverData;
|
|
719
|
+
this.clearHistory();
|
|
720
|
+
this.emit('syncError', originalError ?? new Error('Sync failed'));
|
|
721
|
+
this.emit('reset', { source: 'system' });
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
console.error('[Space] Failed to resync from server:', error);
|
|
725
|
+
// Still emit syncError with the original error
|
|
726
|
+
this.emit('syncError', originalError ?? new Error('Sync failed'));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
//# sourceMappingURL=space.js.map
|