@rool-dev/sdk 0.1.0
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 +1070 -0
- package/dist/apps.d.ts +29 -0
- package/dist/apps.d.ts.map +1 -0
- package/dist/apps.js +88 -0
- package/dist/apps.js.map +1 -0
- package/dist/auth-browser.d.ts +80 -0
- package/dist/auth-browser.d.ts.map +1 -0
- package/dist/auth-browser.js +370 -0
- package/dist/auth-browser.js.map +1 -0
- package/dist/auth-node.d.ts +46 -0
- package/dist/auth-node.d.ts.map +1 -0
- package/dist/auth-node.js +316 -0
- package/dist/auth-node.js.map +1 -0
- package/dist/auth.d.ts +56 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +96 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +202 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +472 -0
- package/dist/client.js.map +1 -0
- package/dist/event-emitter.d.ts +38 -0
- package/dist/event-emitter.d.ts.map +1 -0
- package/dist/event-emitter.js +80 -0
- package/dist/event-emitter.js.map +1 -0
- package/dist/graphql.d.ts +71 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +487 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonld.d.ts +47 -0
- package/dist/jsonld.d.ts.map +1 -0
- package/dist/jsonld.js +137 -0
- package/dist/jsonld.js.map +1 -0
- package/dist/media.d.ts +52 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +173 -0
- package/dist/media.js.map +1 -0
- package/dist/space.d.ts +358 -0
- package/dist/space.d.ts.map +1 -0
- package/dist/space.js +1121 -0
- package/dist/space.js.map +1 -0
- package/dist/subscription.d.ts +57 -0
- package/dist/subscription.d.ts.map +1 -0
- package/dist/subscription.js +296 -0
- package/dist/subscription.js.map +1 -0
- package/dist/types.d.ts +409 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +65 -0
package/dist/space.js
ADDED
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
import { immutableJSONPatch } from 'immutable-json-patch';
|
|
2
|
+
import { zipSync, unzipSync } from 'fflate';
|
|
3
|
+
import { EventEmitter } from './event-emitter.js';
|
|
4
|
+
import { SpaceSubscriptionManager } from './subscription.js';
|
|
5
|
+
import { toJsonLd, fromJsonLd, findAllStrings, rewriteStrings } from './jsonld.js';
|
|
6
|
+
// 6-character alphanumeric ID (62^6 = 56.8 billion possible values)
|
|
7
|
+
const ID_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
8
|
+
export function generateEntityId() {
|
|
9
|
+
let result = '';
|
|
10
|
+
for (let i = 0; i < 6; i++) {
|
|
11
|
+
result += ID_CHARS[Math.floor(Math.random() * ID_CHARS.length)];
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
// Content type <-> file extension mapping for archive media files
|
|
16
|
+
const MIME_TO_EXT = {
|
|
17
|
+
'image/jpeg': '.jpg',
|
|
18
|
+
'image/png': '.png',
|
|
19
|
+
'image/gif': '.gif',
|
|
20
|
+
'image/webp': '.webp',
|
|
21
|
+
'image/svg+xml': '.svg',
|
|
22
|
+
'audio/mpeg': '.mp3',
|
|
23
|
+
'audio/wav': '.wav',
|
|
24
|
+
'audio/ogg': '.ogg',
|
|
25
|
+
'video/mp4': '.mp4',
|
|
26
|
+
'video/webm': '.webm',
|
|
27
|
+
'application/pdf': '.pdf',
|
|
28
|
+
'text/plain': '.txt',
|
|
29
|
+
'application/json': '.json',
|
|
30
|
+
};
|
|
31
|
+
const EXT_TO_MIME = {
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.png': 'image/png',
|
|
35
|
+
'.gif': 'image/gif',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.svg': 'image/svg+xml',
|
|
38
|
+
'.mp3': 'audio/mpeg',
|
|
39
|
+
'.wav': 'audio/wav',
|
|
40
|
+
'.ogg': 'audio/ogg',
|
|
41
|
+
'.mp4': 'video/mp4',
|
|
42
|
+
'.webm': 'video/webm',
|
|
43
|
+
'.pdf': 'application/pdf',
|
|
44
|
+
'.txt': 'text/plain',
|
|
45
|
+
'.json': 'application/json',
|
|
46
|
+
};
|
|
47
|
+
function getExtensionFromContentType(contentType) {
|
|
48
|
+
// Strip parameters like charset
|
|
49
|
+
const base = contentType.split(';')[0].trim();
|
|
50
|
+
return MIME_TO_EXT[base] ?? '.bin';
|
|
51
|
+
}
|
|
52
|
+
function getContentTypeFromFilename(filename) {
|
|
53
|
+
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
|
54
|
+
return EXT_TO_MIME[ext] ?? 'application/octet-stream';
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* First-class Space object.
|
|
58
|
+
*
|
|
59
|
+
* Features:
|
|
60
|
+
* - High-level object/link operations
|
|
61
|
+
* - Built-in undo/redo with checkpoints
|
|
62
|
+
* - Metadata management
|
|
63
|
+
* - Event emission for state changes
|
|
64
|
+
* - Real-time updates via space-specific subscription
|
|
65
|
+
*/
|
|
66
|
+
export class RoolSpace extends EventEmitter {
|
|
67
|
+
_id;
|
|
68
|
+
_name;
|
|
69
|
+
_role;
|
|
70
|
+
_userId;
|
|
71
|
+
_conversationId;
|
|
72
|
+
_data;
|
|
73
|
+
graphqlClient;
|
|
74
|
+
mediaClient;
|
|
75
|
+
subscriptionManager;
|
|
76
|
+
onCloseCallback;
|
|
77
|
+
constructor(config) {
|
|
78
|
+
super();
|
|
79
|
+
this._id = config.id;
|
|
80
|
+
this._name = config.name;
|
|
81
|
+
this._role = config.role;
|
|
82
|
+
this._userId = config.userId;
|
|
83
|
+
this._conversationId = config.conversationId ?? generateEntityId();
|
|
84
|
+
this._data = config.initialData;
|
|
85
|
+
this.graphqlClient = config.graphqlClient;
|
|
86
|
+
this.mediaClient = config.mediaClient;
|
|
87
|
+
this.onCloseCallback = config.onClose;
|
|
88
|
+
// Create space-level subscription
|
|
89
|
+
this.subscriptionManager = new SpaceSubscriptionManager({
|
|
90
|
+
graphqlUrl: config.graphqlUrl,
|
|
91
|
+
authManager: config.authManager,
|
|
92
|
+
spaceId: this._id,
|
|
93
|
+
conversationId: this._conversationId,
|
|
94
|
+
onEvent: (event) => this.handleSpaceEvent(event),
|
|
95
|
+
onConnectionStateChanged: () => {
|
|
96
|
+
// Space-level connection state (could emit events if needed)
|
|
97
|
+
},
|
|
98
|
+
onError: (error) => {
|
|
99
|
+
console.error(`[RoolSpace ${this._id}] Subscription error:`, error);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
// Start subscription
|
|
103
|
+
void this.subscriptionManager.subscribe();
|
|
104
|
+
}
|
|
105
|
+
// ===========================================================================
|
|
106
|
+
// Properties
|
|
107
|
+
// ===========================================================================
|
|
108
|
+
get id() {
|
|
109
|
+
return this._id;
|
|
110
|
+
}
|
|
111
|
+
get name() {
|
|
112
|
+
return this._name;
|
|
113
|
+
}
|
|
114
|
+
get role() {
|
|
115
|
+
return this._role;
|
|
116
|
+
}
|
|
117
|
+
/** Current user's ID (for identifying own interactions) */
|
|
118
|
+
get userId() {
|
|
119
|
+
return this._userId;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get the conversation ID for this space instance.
|
|
123
|
+
* Used for AI context tracking and echo suppression.
|
|
124
|
+
*/
|
|
125
|
+
get conversationId() {
|
|
126
|
+
return this._conversationId;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Set the conversation ID for AI context tracking.
|
|
130
|
+
* Emits 'conversationIdChanged' event.
|
|
131
|
+
*/
|
|
132
|
+
set conversationId(value) {
|
|
133
|
+
if (value === this._conversationId)
|
|
134
|
+
return;
|
|
135
|
+
const previous = this._conversationId;
|
|
136
|
+
this._conversationId = value;
|
|
137
|
+
this.emit('conversationIdChanged', {
|
|
138
|
+
previousConversationId: previous,
|
|
139
|
+
newConversationId: value,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
get isReadOnly() {
|
|
143
|
+
return this._role === 'viewer';
|
|
144
|
+
}
|
|
145
|
+
// ===========================================================================
|
|
146
|
+
// Conversation Access
|
|
147
|
+
// ===========================================================================
|
|
148
|
+
/**
|
|
149
|
+
* Get interactions for this space's current conversationId.
|
|
150
|
+
* Returns the interactions array.
|
|
151
|
+
*/
|
|
152
|
+
getInteractions() {
|
|
153
|
+
return this._data.conversations?.[this._conversationId]?.interactions ?? [];
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Get interactions for a specific conversation ID.
|
|
157
|
+
* Useful for viewing other conversations in the space.
|
|
158
|
+
*/
|
|
159
|
+
getInteractionsById(conversationId) {
|
|
160
|
+
return this._data.conversations?.[conversationId]?.interactions ?? [];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Get all conversation IDs that have conversations in this space.
|
|
164
|
+
*/
|
|
165
|
+
getConversationIds() {
|
|
166
|
+
return Object.keys(this._data.conversations ?? {});
|
|
167
|
+
}
|
|
168
|
+
// ===========================================================================
|
|
169
|
+
// Space Lifecycle
|
|
170
|
+
// ===========================================================================
|
|
171
|
+
/**
|
|
172
|
+
* Rename this space.
|
|
173
|
+
*/
|
|
174
|
+
async rename(newName) {
|
|
175
|
+
const oldName = this._name;
|
|
176
|
+
this._name = newName;
|
|
177
|
+
try {
|
|
178
|
+
await this.graphqlClient.renameSpace(this._id, newName, this._conversationId);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
this._name = oldName;
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Close this space and clean up resources.
|
|
187
|
+
* Stops real-time subscription and unregisters from client.
|
|
188
|
+
*/
|
|
189
|
+
close() {
|
|
190
|
+
this.subscriptionManager.destroy();
|
|
191
|
+
this.onCloseCallback(this._id);
|
|
192
|
+
this.removeAllListeners();
|
|
193
|
+
}
|
|
194
|
+
// ===========================================================================
|
|
195
|
+
// Undo / Redo (Server-managed checkpoints)
|
|
196
|
+
// ===========================================================================
|
|
197
|
+
/**
|
|
198
|
+
* Create a checkpoint (seal current batch of changes).
|
|
199
|
+
* Patches accumulate automatically - this seals them with a label.
|
|
200
|
+
* @returns The checkpoint ID
|
|
201
|
+
*/
|
|
202
|
+
async checkpoint(label = 'Change') {
|
|
203
|
+
const result = await this.graphqlClient.checkpoint(this._id, label, this._conversationId);
|
|
204
|
+
return result.checkpointId;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Check if undo is available.
|
|
208
|
+
*/
|
|
209
|
+
async canUndo() {
|
|
210
|
+
const status = await this.graphqlClient.checkpointStatus(this._id, this._conversationId);
|
|
211
|
+
return status.canUndo;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Check if redo is available.
|
|
215
|
+
*/
|
|
216
|
+
async canRedo() {
|
|
217
|
+
const status = await this.graphqlClient.checkpointStatus(this._id, this._conversationId);
|
|
218
|
+
return status.canRedo;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Undo the most recent batch of changes.
|
|
222
|
+
* Reverses your most recent batch (sealed or open).
|
|
223
|
+
* Conflicting patches (modified by others) are silently skipped.
|
|
224
|
+
* @returns true if undo was performed
|
|
225
|
+
*/
|
|
226
|
+
async undo() {
|
|
227
|
+
const result = await this.graphqlClient.undo(this._id, this._conversationId);
|
|
228
|
+
// Server broadcasts space_patched if successful, which updates local state
|
|
229
|
+
return result.success;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Redo a previously undone batch of changes.
|
|
233
|
+
* @returns true if redo was performed
|
|
234
|
+
*/
|
|
235
|
+
async redo() {
|
|
236
|
+
const result = await this.graphqlClient.redo(this._id, this._conversationId);
|
|
237
|
+
// Server broadcasts space_patched if successful, which updates local state
|
|
238
|
+
return result.success;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Clear checkpoint history for this conversation.
|
|
242
|
+
*/
|
|
243
|
+
async clearHistory() {
|
|
244
|
+
await this.graphqlClient.clearCheckpointHistory(this._id, this._conversationId);
|
|
245
|
+
}
|
|
246
|
+
// ===========================================================================
|
|
247
|
+
// Object Operations
|
|
248
|
+
// ===========================================================================
|
|
249
|
+
/**
|
|
250
|
+
* Get an object's data by ID.
|
|
251
|
+
* Returns just the data portion (RoolObject), not the full entry with meta/links.
|
|
252
|
+
*/
|
|
253
|
+
async getObject(objectId) {
|
|
254
|
+
return this._data.objects[objectId]?.data;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Get an object's stat (audit information).
|
|
258
|
+
* Returns modification timestamp and author, or undefined if object not found.
|
|
259
|
+
*/
|
|
260
|
+
async stat(objectId) {
|
|
261
|
+
const entry = this._data.objects[objectId];
|
|
262
|
+
if (!entry)
|
|
263
|
+
return undefined;
|
|
264
|
+
return {
|
|
265
|
+
modifiedAt: entry.modifiedAt,
|
|
266
|
+
modifiedBy: entry.modifiedBy,
|
|
267
|
+
modifiedByName: entry.modifiedByName,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Find objects using structured filters and natural language.
|
|
272
|
+
* @param options.where - Structured field requirements (exact match). Use {{placeholder}} for semantic matching.
|
|
273
|
+
* @param options.prompt - Natural language query/refinement
|
|
274
|
+
* @param options.limit - Maximum number of results to return
|
|
275
|
+
* @param options.objectIds - Scope search to specific objects
|
|
276
|
+
* @returns The matching objects and a message from the AI
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* // Exact match
|
|
280
|
+
* const { objects } = await space.findObjects({ where: { type: 'article' } });
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* // Natural language
|
|
284
|
+
* const { objects, message } = await space.findObjects({
|
|
285
|
+
* prompt: 'articles about space exploration'
|
|
286
|
+
* });
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* // Combined - structured + semantic
|
|
290
|
+
* const { objects } = await space.findObjects({
|
|
291
|
+
* where: { type: 'article', category: '{{something about food}}' },
|
|
292
|
+
* prompt: 'published in the last month',
|
|
293
|
+
* limit: 10
|
|
294
|
+
* });
|
|
295
|
+
*/
|
|
296
|
+
async findObjects(options) {
|
|
297
|
+
const order = options.order ?? 'desc';
|
|
298
|
+
// Check if we need AI (prompt or placeholders in where)
|
|
299
|
+
const needsAI = options.prompt ||
|
|
300
|
+
(options.where && JSON.stringify(options.where).includes('{{'));
|
|
301
|
+
// If no AI needed, filter locally (avoids server round trip)
|
|
302
|
+
if (!needsAI) {
|
|
303
|
+
// Get entries (not just data) so we can sort by modifiedAt
|
|
304
|
+
let entries = Object.entries(this._data.objects);
|
|
305
|
+
// Apply where clause (exact match)
|
|
306
|
+
if (options.where && Object.keys(options.where).length > 0) {
|
|
307
|
+
entries = entries.filter(([, entry]) => Object.entries(options.where).every(([key, value]) => entry.data[key] === value));
|
|
308
|
+
}
|
|
309
|
+
// Apply scope filter
|
|
310
|
+
if (options.objectIds && options.objectIds.length > 0) {
|
|
311
|
+
const scope = new Set(options.objectIds);
|
|
312
|
+
entries = entries.filter(([id]) => scope.has(id));
|
|
313
|
+
}
|
|
314
|
+
// Sort by modifiedAt
|
|
315
|
+
entries.sort((a, b) => {
|
|
316
|
+
const aTime = a[1].modifiedAt ?? 0;
|
|
317
|
+
const bTime = b[1].modifiedAt ?? 0;
|
|
318
|
+
return order === 'desc' ? bTime - aTime : aTime - bTime;
|
|
319
|
+
});
|
|
320
|
+
// Apply limit
|
|
321
|
+
if (options.limit) {
|
|
322
|
+
entries = entries.slice(0, options.limit);
|
|
323
|
+
}
|
|
324
|
+
const objects = entries.map(([, entry]) => entry.data);
|
|
325
|
+
return {
|
|
326
|
+
objects,
|
|
327
|
+
message: `Found ${objects.length} object(s) matching criteria`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
// Otherwise, use server (with AI)
|
|
331
|
+
return this.graphqlClient.findObjects(this._id, options, this._conversationId);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Get all object IDs.
|
|
335
|
+
* @param options.limit - Maximum number of IDs to return
|
|
336
|
+
* @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
|
|
337
|
+
*/
|
|
338
|
+
getObjectIds(options) {
|
|
339
|
+
const order = options?.order ?? 'desc';
|
|
340
|
+
let entries = Object.entries(this._data.objects);
|
|
341
|
+
// Sort by modifiedAt
|
|
342
|
+
entries.sort((a, b) => {
|
|
343
|
+
const aTime = a[1].modifiedAt ?? 0;
|
|
344
|
+
const bTime = b[1].modifiedAt ?? 0;
|
|
345
|
+
return order === 'desc' ? bTime - aTime : aTime - bTime;
|
|
346
|
+
});
|
|
347
|
+
let ids = entries.map(([id]) => id);
|
|
348
|
+
if (options?.limit) {
|
|
349
|
+
ids = ids.slice(0, options.limit);
|
|
350
|
+
}
|
|
351
|
+
return ids;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Create a new object with optional AI generation.
|
|
355
|
+
* @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.
|
|
356
|
+
* @param options.prompt - AI prompt for content generation (optional).
|
|
357
|
+
* @param options.ephemeral - If true, the operation won't be recorded in conversation history.
|
|
358
|
+
* @returns The created object (with AI-filled content) and message
|
|
359
|
+
*/
|
|
360
|
+
async createObject(options) {
|
|
361
|
+
const { data, prompt, ephemeral } = options;
|
|
362
|
+
// Use data.id if provided (string), otherwise generate
|
|
363
|
+
const objectId = typeof data.id === 'string' ? data.id : generateEntityId();
|
|
364
|
+
// Validate ID format: alphanumeric, hyphens, underscores only
|
|
365
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(objectId)) {
|
|
366
|
+
throw new Error(`Invalid object ID "${objectId}". IDs must contain only alphanumeric characters, hyphens, and underscores.`);
|
|
367
|
+
}
|
|
368
|
+
// Fail if object already exists
|
|
369
|
+
if (this._data.objects[objectId]) {
|
|
370
|
+
throw new Error(`Object "${objectId}" already exists`);
|
|
371
|
+
}
|
|
372
|
+
const dataWithId = { ...data, id: objectId };
|
|
373
|
+
// Build the entry for local state (optimistic - server will overwrite audit fields)
|
|
374
|
+
const entry = {
|
|
375
|
+
links: {},
|
|
376
|
+
data: dataWithId,
|
|
377
|
+
modifiedAt: Date.now(),
|
|
378
|
+
modifiedBy: this._userId,
|
|
379
|
+
modifiedByName: null,
|
|
380
|
+
};
|
|
381
|
+
// Update local state immediately (optimistic)
|
|
382
|
+
this._data.objects[objectId] = entry;
|
|
383
|
+
this.emit('objectCreated', { objectId, object: entry.data, source: 'local_user' });
|
|
384
|
+
// Await server call (may trigger AI processing that updates local state via patches)
|
|
385
|
+
try {
|
|
386
|
+
const message = await this.graphqlClient.createObject(this.id, dataWithId, this._conversationId, prompt, ephemeral);
|
|
387
|
+
// Return current state (may have been updated by AI patches)
|
|
388
|
+
return { object: this._data.objects[objectId].data, message };
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
console.error('[Space] Failed to create object:', error);
|
|
392
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Update an existing object.
|
|
398
|
+
* @param objectId - The ID of the object to update
|
|
399
|
+
* @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.
|
|
400
|
+
* @param options.prompt - AI prompt for content editing (optional).
|
|
401
|
+
* @param options.ephemeral - If true, the operation won't be recorded in conversation history.
|
|
402
|
+
* @returns The updated object (with AI-filled content) and message
|
|
403
|
+
*/
|
|
404
|
+
async updateObject(objectId, options) {
|
|
405
|
+
const entry = this._data.objects[objectId];
|
|
406
|
+
if (!entry) {
|
|
407
|
+
throw new Error(`Object ${objectId} not found for update`);
|
|
408
|
+
}
|
|
409
|
+
const { data, ephemeral } = options;
|
|
410
|
+
// id is immutable after creation (but null/undefined means delete attempt, which we also reject)
|
|
411
|
+
if (data?.id !== undefined && data.id !== null) {
|
|
412
|
+
throw new Error('Cannot change id in updateObject. The id field is immutable after creation.');
|
|
413
|
+
}
|
|
414
|
+
if (data && ('id' in data)) {
|
|
415
|
+
throw new Error('Cannot delete id field. The id field is immutable after creation.');
|
|
416
|
+
}
|
|
417
|
+
// Normalize undefined to null (for JSON serialization) and build server data
|
|
418
|
+
let serverData;
|
|
419
|
+
if (data) {
|
|
420
|
+
serverData = {};
|
|
421
|
+
for (const [key, value] of Object.entries(data)) {
|
|
422
|
+
// Convert undefined to null for wire protocol
|
|
423
|
+
serverData[key] = value === undefined ? null : value;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Build local updates (apply deletions and updates)
|
|
427
|
+
if (data) {
|
|
428
|
+
for (const [key, value] of Object.entries(data)) {
|
|
429
|
+
if (value === null || value === undefined) {
|
|
430
|
+
delete entry.data[key];
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
entry.data[key] = value;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// Emit semantic event with updated object
|
|
438
|
+
if (data) {
|
|
439
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source: 'local_user' });
|
|
440
|
+
}
|
|
441
|
+
// Await server call (may trigger AI processing that updates local state via patches)
|
|
442
|
+
try {
|
|
443
|
+
const message = await this.graphqlClient.updateObject(this.id, objectId, this._conversationId, serverData, options.prompt, ephemeral);
|
|
444
|
+
// Return current state (may have been updated by AI patches)
|
|
445
|
+
return { object: this._data.objects[objectId].data, message };
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
console.error('[Space] Failed to update object:', error);
|
|
449
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Delete objects by IDs.
|
|
455
|
+
* Outbound links are automatically deleted with the object.
|
|
456
|
+
* Inbound links become orphans (tolerated).
|
|
457
|
+
*/
|
|
458
|
+
async deleteObjects(objectIds) {
|
|
459
|
+
if (objectIds.length === 0)
|
|
460
|
+
return;
|
|
461
|
+
const deletedObjectIds = [];
|
|
462
|
+
// Collect links that will be orphaned (for events)
|
|
463
|
+
const deletedLinks = [];
|
|
464
|
+
for (const objectId of objectIds) {
|
|
465
|
+
const entry = this._data.objects[objectId];
|
|
466
|
+
if (entry) {
|
|
467
|
+
// Collect outbound links for deletion events
|
|
468
|
+
for (const [relation, targets] of Object.entries(entry.links)) {
|
|
469
|
+
for (const targetId of Object.keys(targets)) {
|
|
470
|
+
deletedLinks.push({ sourceId: objectId, targetId, relation });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Remove objects (local state)
|
|
476
|
+
for (const objectId of objectIds) {
|
|
477
|
+
if (this._data.objects[objectId]) {
|
|
478
|
+
delete this._data.objects[objectId];
|
|
479
|
+
deletedObjectIds.push(objectId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Emit semantic events
|
|
483
|
+
for (const link of deletedLinks) {
|
|
484
|
+
this.emit('unlinked', { ...link, source: 'local_user' });
|
|
485
|
+
}
|
|
486
|
+
for (const objectId of deletedObjectIds) {
|
|
487
|
+
this.emit('objectDeleted', { objectId, source: 'local_user' });
|
|
488
|
+
}
|
|
489
|
+
// Await server call
|
|
490
|
+
try {
|
|
491
|
+
await this.graphqlClient.deleteObjects(this.id, objectIds, this._conversationId);
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
console.error('[Space] Failed to delete objects:', error);
|
|
495
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
496
|
+
throw error;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// ===========================================================================
|
|
500
|
+
// Conversation Management
|
|
501
|
+
// ===========================================================================
|
|
502
|
+
/**
|
|
503
|
+
* Delete a conversation and its interaction history.
|
|
504
|
+
* Defaults to the current conversation if no conversationId is provided.
|
|
505
|
+
*/
|
|
506
|
+
async deleteConversation(conversationId) {
|
|
507
|
+
const targetConversationId = conversationId ?? this._conversationId;
|
|
508
|
+
// Optimistic local update
|
|
509
|
+
if (this._data.conversations?.[targetConversationId]) {
|
|
510
|
+
delete this._data.conversations[targetConversationId];
|
|
511
|
+
}
|
|
512
|
+
// Emit event
|
|
513
|
+
this.emit('conversationUpdated', {
|
|
514
|
+
conversationId: targetConversationId,
|
|
515
|
+
source: 'local_user',
|
|
516
|
+
});
|
|
517
|
+
// Call server
|
|
518
|
+
try {
|
|
519
|
+
await this.graphqlClient.deleteConversation(this.id, targetConversationId);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
console.error('[Space] Failed to delete conversation:', error);
|
|
523
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
524
|
+
throw error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Rename a conversation.
|
|
529
|
+
* If the conversation doesn't exist, it will be created with the given name.
|
|
530
|
+
*/
|
|
531
|
+
async renameConversation(conversationId, name) {
|
|
532
|
+
// Optimistic local update - auto-create if needed
|
|
533
|
+
if (!this._data.conversations) {
|
|
534
|
+
this._data.conversations = {};
|
|
535
|
+
}
|
|
536
|
+
if (!this._data.conversations[conversationId]) {
|
|
537
|
+
this._data.conversations[conversationId] = {
|
|
538
|
+
name,
|
|
539
|
+
createdAt: Date.now(),
|
|
540
|
+
interactions: [],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
this._data.conversations[conversationId].name = name;
|
|
545
|
+
}
|
|
546
|
+
// Emit event
|
|
547
|
+
this.emit('conversationUpdated', {
|
|
548
|
+
conversationId,
|
|
549
|
+
source: 'local_user',
|
|
550
|
+
});
|
|
551
|
+
// Call server
|
|
552
|
+
try {
|
|
553
|
+
await this.graphqlClient.renameConversation(this.id, conversationId, name);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
console.error('[Space] Failed to rename conversation:', error);
|
|
557
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* List all conversations in this space with summary info.
|
|
563
|
+
*/
|
|
564
|
+
async listConversations() {
|
|
565
|
+
return this.graphqlClient.listConversations(this.id);
|
|
566
|
+
}
|
|
567
|
+
// ===========================================================================
|
|
568
|
+
// Link Operations
|
|
569
|
+
// ===========================================================================
|
|
570
|
+
/**
|
|
571
|
+
* Create a link between objects.
|
|
572
|
+
* Links are stored on the source object.
|
|
573
|
+
*/
|
|
574
|
+
async link(sourceId, relation, targetId) {
|
|
575
|
+
const entry = this._data.objects[sourceId];
|
|
576
|
+
if (!entry) {
|
|
577
|
+
throw new Error(`Source object ${sourceId} not found`);
|
|
578
|
+
}
|
|
579
|
+
// Update local state immediately
|
|
580
|
+
if (!entry.links[relation]) {
|
|
581
|
+
entry.links[relation] = [];
|
|
582
|
+
}
|
|
583
|
+
if (!entry.links[relation].includes(targetId)) {
|
|
584
|
+
entry.links[relation].push(targetId);
|
|
585
|
+
}
|
|
586
|
+
this.emit('linked', { sourceId, relation, targetId, source: 'local_user' });
|
|
587
|
+
// Await server call
|
|
588
|
+
try {
|
|
589
|
+
await this.graphqlClient.link(this.id, sourceId, relation, targetId, this._conversationId);
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
console.error('[Space] Failed to create link:', error);
|
|
593
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Remove links from a source object.
|
|
599
|
+
* Three forms:
|
|
600
|
+
* - unlink(source, relation, target): remove one specific link
|
|
601
|
+
* - unlink(source, relation): clear all targets for that relation
|
|
602
|
+
* - unlink(source): clear ALL relations on the source
|
|
603
|
+
* @returns true if any links were removed
|
|
604
|
+
*/
|
|
605
|
+
async unlink(sourceId, relation, targetId) {
|
|
606
|
+
const entry = this._data.objects[sourceId];
|
|
607
|
+
if (!entry) {
|
|
608
|
+
throw new Error(`Source object ${sourceId} not found`);
|
|
609
|
+
}
|
|
610
|
+
const deletedLinks = [];
|
|
611
|
+
// Update local state based on which parameters are provided
|
|
612
|
+
if (relation && targetId) {
|
|
613
|
+
// Remove one specific link: source.relation -> target
|
|
614
|
+
const existing = entry.links[relation] ?? [];
|
|
615
|
+
if (existing.includes(targetId)) {
|
|
616
|
+
entry.links[relation] = existing.filter(t => t !== targetId);
|
|
617
|
+
if (entry.links[relation].length === 0) {
|
|
618
|
+
delete entry.links[relation];
|
|
619
|
+
}
|
|
620
|
+
deletedLinks.push({ relation, targetId });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
else if (relation && !targetId) {
|
|
624
|
+
// Clear all targets for this relation
|
|
625
|
+
if (entry.links[relation]) {
|
|
626
|
+
for (const target of entry.links[relation]) {
|
|
627
|
+
deletedLinks.push({ relation, targetId: target });
|
|
628
|
+
}
|
|
629
|
+
delete entry.links[relation];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
else if (!relation && !targetId) {
|
|
633
|
+
// Clear ALL relations on the source
|
|
634
|
+
for (const [rel, targets] of Object.entries(entry.links)) {
|
|
635
|
+
for (const target of targets) {
|
|
636
|
+
deletedLinks.push({ relation: rel, targetId: target });
|
|
637
|
+
}
|
|
638
|
+
delete entry.links[rel];
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// Emit semantic events
|
|
642
|
+
for (const link of deletedLinks) {
|
|
643
|
+
this.emit('unlinked', { sourceId, relation: link.relation, targetId: link.targetId, source: 'local_user' });
|
|
644
|
+
}
|
|
645
|
+
// Await server call
|
|
646
|
+
try {
|
|
647
|
+
await this.graphqlClient.unlink(this.id, sourceId, relation, targetId, this._conversationId);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
console.error('[Space] Failed to remove link:', error);
|
|
651
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
return deletedLinks.length > 0;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Get parent objects (objects that have links pointing TO this object).
|
|
658
|
+
* @param relation - Optional filter by relation name
|
|
659
|
+
* @param options.limit - Maximum number of parents to return
|
|
660
|
+
* @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
|
|
661
|
+
*/
|
|
662
|
+
async getParents(objectId, relation, options) {
|
|
663
|
+
const order = options?.order ?? 'desc';
|
|
664
|
+
const parentEntries = [];
|
|
665
|
+
for (const [id, entry] of Object.entries(this._data.objects)) {
|
|
666
|
+
for (const [rel, targets] of Object.entries(entry.links)) {
|
|
667
|
+
if ((!relation || rel === relation) && targets.includes(objectId)) {
|
|
668
|
+
parentEntries.push([id, entry]);
|
|
669
|
+
break; // Found a link, move to next object
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// Sort by modifiedAt
|
|
674
|
+
parentEntries.sort((a, b) => {
|
|
675
|
+
const aTime = a[1].modifiedAt ?? 0;
|
|
676
|
+
const bTime = b[1].modifiedAt ?? 0;
|
|
677
|
+
return order === 'desc' ? bTime - aTime : aTime - bTime;
|
|
678
|
+
});
|
|
679
|
+
let parents = parentEntries.map(([, entry]) => entry.data);
|
|
680
|
+
if (options?.limit) {
|
|
681
|
+
parents = parents.slice(0, options.limit);
|
|
682
|
+
}
|
|
683
|
+
return parents;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Get child objects (objects that this object has links pointing TO).
|
|
687
|
+
* Filters out orphan targets (targets that don't exist).
|
|
688
|
+
* @param relation - Optional filter by relation name
|
|
689
|
+
* @param options.limit - Maximum number of children to return
|
|
690
|
+
* @param options.order - Sort order by modifiedAt ('asc' or 'desc', default: 'desc')
|
|
691
|
+
*/
|
|
692
|
+
async getChildren(objectId, relation, options) {
|
|
693
|
+
const entry = this._data.objects[objectId];
|
|
694
|
+
if (!entry)
|
|
695
|
+
return [];
|
|
696
|
+
const order = options?.order ?? 'desc';
|
|
697
|
+
const childEntries = [];
|
|
698
|
+
for (const [rel, targets] of Object.entries(entry.links)) {
|
|
699
|
+
if (!relation || rel === relation) {
|
|
700
|
+
for (const targetId of targets) {
|
|
701
|
+
// Filter orphans - only include existing targets
|
|
702
|
+
const targetEntry = this._data.objects[targetId];
|
|
703
|
+
if (targetEntry) {
|
|
704
|
+
childEntries.push([targetId, targetEntry]);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Sort by modifiedAt
|
|
710
|
+
childEntries.sort((a, b) => {
|
|
711
|
+
const aTime = a[1].modifiedAt ?? 0;
|
|
712
|
+
const bTime = b[1].modifiedAt ?? 0;
|
|
713
|
+
return order === 'desc' ? bTime - aTime : aTime - bTime;
|
|
714
|
+
});
|
|
715
|
+
let children = childEntries.map(([, entry]) => entry.data);
|
|
716
|
+
if (options?.limit) {
|
|
717
|
+
children = children.slice(0, options.limit);
|
|
718
|
+
}
|
|
719
|
+
return children;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Get all child object IDs including orphans (targets that may not exist).
|
|
723
|
+
* @param relation - Optional filter by relation name
|
|
724
|
+
*/
|
|
725
|
+
getChildrenIncludingOrphans(objectId, relation) {
|
|
726
|
+
const entry = this._data.objects[objectId];
|
|
727
|
+
if (!entry)
|
|
728
|
+
return [];
|
|
729
|
+
const children = [];
|
|
730
|
+
for (const [rel, targets] of Object.entries(entry.links)) {
|
|
731
|
+
if (!relation || rel === relation) {
|
|
732
|
+
children.push(...targets);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return children;
|
|
736
|
+
}
|
|
737
|
+
// ===========================================================================
|
|
738
|
+
// Metadata Operations
|
|
739
|
+
// ===========================================================================
|
|
740
|
+
/**
|
|
741
|
+
* Set a space-level metadata value.
|
|
742
|
+
* Metadata is stored in meta and hidden from AI operations.
|
|
743
|
+
*/
|
|
744
|
+
setMetadata(key, value) {
|
|
745
|
+
if (!this._data.meta) {
|
|
746
|
+
this._data.meta = {};
|
|
747
|
+
}
|
|
748
|
+
this._data.meta[key] = value;
|
|
749
|
+
this.emit('metadataUpdated', { metadata: this._data.meta, source: 'local_user' });
|
|
750
|
+
// Fire-and-forget server call - errors trigger resync
|
|
751
|
+
this.graphqlClient.setSpaceMeta(this.id, this._data.meta, this._conversationId)
|
|
752
|
+
.catch((error) => {
|
|
753
|
+
console.error('[Space] Failed to set meta:', error);
|
|
754
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error)));
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Get a space-level metadata value.
|
|
759
|
+
*/
|
|
760
|
+
getMetadata(key) {
|
|
761
|
+
return this._data.meta?.[key];
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Get all space-level metadata.
|
|
765
|
+
*/
|
|
766
|
+
getAllMetadata() {
|
|
767
|
+
return this._data.meta ?? {};
|
|
768
|
+
}
|
|
769
|
+
// ===========================================================================
|
|
770
|
+
// AI Operations
|
|
771
|
+
// ===========================================================================
|
|
772
|
+
/**
|
|
773
|
+
* Send a prompt to the AI agent for space manipulation.
|
|
774
|
+
* @returns The message from the AI and the list of objects that were created or modified
|
|
775
|
+
*/
|
|
776
|
+
async prompt(prompt, options) {
|
|
777
|
+
const result = await this.graphqlClient.prompt(this._id, prompt, this._conversationId, options);
|
|
778
|
+
// Hydrate modified object IDs to actual objects (filter out deleted ones)
|
|
779
|
+
const objects = result.modifiedObjectIds
|
|
780
|
+
.map(id => this._data.objects[id]?.data)
|
|
781
|
+
.filter((obj) => obj !== undefined);
|
|
782
|
+
return {
|
|
783
|
+
message: result.message,
|
|
784
|
+
objects,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
// ===========================================================================
|
|
788
|
+
// Collaboration
|
|
789
|
+
// ===========================================================================
|
|
790
|
+
/**
|
|
791
|
+
* List users with access to this space.
|
|
792
|
+
*/
|
|
793
|
+
async listUsers() {
|
|
794
|
+
return this.graphqlClient.listSpaceUsers(this._id);
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Add a user to this space with specified role.
|
|
798
|
+
*/
|
|
799
|
+
async addUser(userId, role) {
|
|
800
|
+
return this.graphqlClient.addSpaceUser(this._id, userId, role);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Remove a user from this space.
|
|
804
|
+
*/
|
|
805
|
+
async removeUser(userId) {
|
|
806
|
+
return this.graphqlClient.removeSpaceUser(this._id, userId);
|
|
807
|
+
}
|
|
808
|
+
// ===========================================================================
|
|
809
|
+
// Media Operations
|
|
810
|
+
// ===========================================================================
|
|
811
|
+
/**
|
|
812
|
+
* List all media files for this space.
|
|
813
|
+
*/
|
|
814
|
+
async listMedia() {
|
|
815
|
+
return this.mediaClient.list(this._id);
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Upload a file to this space. Returns the URL.
|
|
819
|
+
*/
|
|
820
|
+
async uploadMedia(file) {
|
|
821
|
+
return this.mediaClient.upload(this._id, file);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Fetch any URL, returning headers and a blob() method (like fetch Response).
|
|
825
|
+
* Adds auth headers for backend media URLs, fetches external URLs via server proxy if CORS blocks.
|
|
826
|
+
*/
|
|
827
|
+
async fetchMedia(url) {
|
|
828
|
+
return this.mediaClient.fetch(this._id, url);
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Delete a media file by URL.
|
|
832
|
+
*/
|
|
833
|
+
async deleteMedia(url) {
|
|
834
|
+
return this.mediaClient.delete(this._id, url);
|
|
835
|
+
}
|
|
836
|
+
// ===========================================================================
|
|
837
|
+
// Low-level Operations
|
|
838
|
+
// ===========================================================================
|
|
839
|
+
/**
|
|
840
|
+
* Get the full space data.
|
|
841
|
+
* Use sparingly - prefer specific operations.
|
|
842
|
+
*/
|
|
843
|
+
getData() {
|
|
844
|
+
return this._data;
|
|
845
|
+
}
|
|
846
|
+
// ===========================================================================
|
|
847
|
+
// Import/Export
|
|
848
|
+
// ===========================================================================
|
|
849
|
+
/**
|
|
850
|
+
* Export space data as JSON-LD.
|
|
851
|
+
* Returns a JSON-LD document with all objects and their relations.
|
|
852
|
+
* Space metadata and interaction history are not included.
|
|
853
|
+
*/
|
|
854
|
+
export() {
|
|
855
|
+
return toJsonLd(this._data);
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Import JSON-LD data into the space.
|
|
859
|
+
* Creates objects and links from the JSON-LD graph.
|
|
860
|
+
* Space must be empty (throws if objects exist).
|
|
861
|
+
*/
|
|
862
|
+
async import(data) {
|
|
863
|
+
if (Object.keys(this._data.objects).length > 0) {
|
|
864
|
+
throw new Error('Cannot import into non-empty space. Create a new space or delete existing objects first.');
|
|
865
|
+
}
|
|
866
|
+
const parsed = fromJsonLd(data);
|
|
867
|
+
// Create all objects first
|
|
868
|
+
for (const obj of parsed.objects) {
|
|
869
|
+
await this.createObject({ data: obj.data });
|
|
870
|
+
}
|
|
871
|
+
// Then create all links
|
|
872
|
+
for (const obj of parsed.objects) {
|
|
873
|
+
for (const rel of obj.relations) {
|
|
874
|
+
await this.link(obj.id, rel.relation, rel.targetId);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Export space data and media as a zip archive.
|
|
880
|
+
* Media URLs are rewritten to relative paths within the archive.
|
|
881
|
+
* @returns A Blob containing the zip archive
|
|
882
|
+
*/
|
|
883
|
+
async exportArchive() {
|
|
884
|
+
// Get JSON-LD export
|
|
885
|
+
const jsonld = this.export();
|
|
886
|
+
// Get all media in this space
|
|
887
|
+
const mediaList = await this.listMedia();
|
|
888
|
+
const mediaUrls = new Set(mediaList.map(m => m.url));
|
|
889
|
+
// Find which media URLs are actually used in the export
|
|
890
|
+
const allStrings = findAllStrings(jsonld);
|
|
891
|
+
const usedMediaUrls = [...allStrings].filter(s => mediaUrls.has(s));
|
|
892
|
+
// Build URL mapping and fetch media files
|
|
893
|
+
const urlMapping = new Map();
|
|
894
|
+
const files = {};
|
|
895
|
+
for (const url of usedMediaUrls) {
|
|
896
|
+
const mediaInfo = mediaList.find(m => m.url === url);
|
|
897
|
+
if (!mediaInfo)
|
|
898
|
+
continue;
|
|
899
|
+
try {
|
|
900
|
+
const response = await this.fetchMedia(url);
|
|
901
|
+
const blob = await response.blob();
|
|
902
|
+
const buffer = await blob.arrayBuffer();
|
|
903
|
+
// Determine filename: uuid + extension from content type
|
|
904
|
+
const ext = getExtensionFromContentType(response.contentType);
|
|
905
|
+
const filename = `${mediaInfo.uuid}${ext}`;
|
|
906
|
+
const relativePath = `media/${filename}`;
|
|
907
|
+
files[relativePath] = new Uint8Array(buffer);
|
|
908
|
+
urlMapping.set(url, relativePath);
|
|
909
|
+
}
|
|
910
|
+
catch (error) {
|
|
911
|
+
// Skip media that fails to fetch (e.g., 404)
|
|
912
|
+
console.warn(`[Space] Failed to fetch media for archive: ${url}`, error);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// Rewrite URLs in JSON-LD
|
|
916
|
+
const rewrittenJsonld = rewriteStrings(jsonld, urlMapping);
|
|
917
|
+
// Add data.json to the archive
|
|
918
|
+
const encoder = new TextEncoder();
|
|
919
|
+
files['data.json'] = encoder.encode(JSON.stringify(rewrittenJsonld, null, 2));
|
|
920
|
+
// Create zip archive
|
|
921
|
+
const zipped = zipSync(files);
|
|
922
|
+
return new Blob([zipped], { type: 'application/zip' });
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Import from a zip archive containing data.json and media files.
|
|
926
|
+
* Space must be empty (throws if objects exist).
|
|
927
|
+
*/
|
|
928
|
+
async importArchive(archive) {
|
|
929
|
+
if (Object.keys(this._data.objects).length > 0) {
|
|
930
|
+
throw new Error('Cannot import into non-empty space. Create a new space or delete existing objects first.');
|
|
931
|
+
}
|
|
932
|
+
// Read and unzip the archive
|
|
933
|
+
const buffer = await archive.arrayBuffer();
|
|
934
|
+
const unzipped = unzipSync(new Uint8Array(buffer));
|
|
935
|
+
// Parse data.json
|
|
936
|
+
const dataJsonBytes = unzipped['data.json'];
|
|
937
|
+
if (!dataJsonBytes) {
|
|
938
|
+
throw new Error('Invalid archive: missing data.json');
|
|
939
|
+
}
|
|
940
|
+
const decoder = new TextDecoder();
|
|
941
|
+
const jsonld = JSON.parse(decoder.decode(dataJsonBytes));
|
|
942
|
+
// Upload media files and build URL mapping
|
|
943
|
+
const urlMapping = new Map();
|
|
944
|
+
for (const [path, data] of Object.entries(unzipped)) {
|
|
945
|
+
if (!path.startsWith('media/'))
|
|
946
|
+
continue;
|
|
947
|
+
const contentType = getContentTypeFromFilename(path);
|
|
948
|
+
const blob = new Blob([data], { type: contentType });
|
|
949
|
+
const newUrl = await this.uploadMedia(blob);
|
|
950
|
+
urlMapping.set(path, newUrl);
|
|
951
|
+
}
|
|
952
|
+
// Rewrite URLs in JSON-LD
|
|
953
|
+
const rewrittenJsonld = rewriteStrings(jsonld, urlMapping);
|
|
954
|
+
// Import using existing logic
|
|
955
|
+
const parsed = fromJsonLd(rewrittenJsonld);
|
|
956
|
+
// Create all objects first
|
|
957
|
+
for (const obj of parsed.objects) {
|
|
958
|
+
await this.createObject({ data: obj.data });
|
|
959
|
+
}
|
|
960
|
+
// Then create all links
|
|
961
|
+
for (const obj of parsed.objects) {
|
|
962
|
+
for (const rel of obj.relations) {
|
|
963
|
+
await this.link(obj.id, rel.relation, rel.targetId);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// ===========================================================================
|
|
968
|
+
// Event Handlers (internal - handles space subscription events)
|
|
969
|
+
// ===========================================================================
|
|
970
|
+
/**
|
|
971
|
+
* Handle a space event from the subscription.
|
|
972
|
+
* @internal
|
|
973
|
+
*/
|
|
974
|
+
handleSpaceEvent(event) {
|
|
975
|
+
switch (event.type) {
|
|
976
|
+
case 'space_patched':
|
|
977
|
+
if (event.patch) {
|
|
978
|
+
this.handleRemotePatch(event.patch, event.source);
|
|
979
|
+
}
|
|
980
|
+
break;
|
|
981
|
+
case 'space_changed':
|
|
982
|
+
// Full reload needed
|
|
983
|
+
void this.graphqlClient.getSpace(this._id).then(({ data }) => {
|
|
984
|
+
this._data = data;
|
|
985
|
+
this.emit('reset', { source: 'remote_user' });
|
|
986
|
+
});
|
|
987
|
+
break;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Check if a patch would actually change the current data.
|
|
992
|
+
* Used to deduplicate events when patches don't change anything (e.g., optimistic updates).
|
|
993
|
+
* @internal
|
|
994
|
+
*/
|
|
995
|
+
didPatchChangeAnything(patch) {
|
|
996
|
+
for (const op of patch) {
|
|
997
|
+
const pathParts = op.path.split('/').filter(p => p);
|
|
998
|
+
let current = this._data;
|
|
999
|
+
for (const part of pathParts) {
|
|
1000
|
+
current = current?.[part];
|
|
1001
|
+
}
|
|
1002
|
+
if (op.op === 'remove' && current !== undefined)
|
|
1003
|
+
return true;
|
|
1004
|
+
if ((op.op === 'add' || op.op === 'replace') &&
|
|
1005
|
+
JSON.stringify(current) !== JSON.stringify(op.value))
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Handle a patch event from another client.
|
|
1012
|
+
* @internal
|
|
1013
|
+
*/
|
|
1014
|
+
handleRemotePatch(patch, source) {
|
|
1015
|
+
// Check if patch would change anything BEFORE applying
|
|
1016
|
+
const willChange = this.didPatchChangeAnything(patch);
|
|
1017
|
+
try {
|
|
1018
|
+
this._data = immutableJSONPatch(this._data, patch);
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
console.error('[Space] Failed to apply remote patch:', error);
|
|
1022
|
+
// Force resync on patch error
|
|
1023
|
+
this.resyncFromServer(error instanceof Error ? error : new Error(String(error))).catch(() => { });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
// Only emit events if something actually changed
|
|
1027
|
+
if (willChange) {
|
|
1028
|
+
const changeSource = source === 'agent' ? 'remote_agent' : 'remote_user';
|
|
1029
|
+
this.emitSemanticEventsFromPatch(patch, changeSource);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Parse JSON patch operations and emit semantic events.
|
|
1034
|
+
* @internal
|
|
1035
|
+
*/
|
|
1036
|
+
emitSemanticEventsFromPatch(patch, source) {
|
|
1037
|
+
// Track which objects have been updated (to avoid duplicate events)
|
|
1038
|
+
const updatedObjects = new Set();
|
|
1039
|
+
for (const op of patch) {
|
|
1040
|
+
const { path } = op;
|
|
1041
|
+
// Object operations: /objects/{objectId}/...
|
|
1042
|
+
if (path.startsWith('/objects/')) {
|
|
1043
|
+
const parts = path.split('/');
|
|
1044
|
+
const objectId = parts[2];
|
|
1045
|
+
if (parts.length === 3) {
|
|
1046
|
+
// /objects/{objectId} - full object add or remove
|
|
1047
|
+
if (op.op === 'add') {
|
|
1048
|
+
const entry = this._data.objects[objectId];
|
|
1049
|
+
if (entry) {
|
|
1050
|
+
this.emit('objectCreated', { objectId, object: entry.data, source });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
else if (op.op === 'remove') {
|
|
1054
|
+
this.emit('objectDeleted', { objectId, source });
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
else if (parts[3] === 'data') {
|
|
1058
|
+
// /objects/{objectId}/data/... - data field update
|
|
1059
|
+
if (!updatedObjects.has(objectId)) {
|
|
1060
|
+
const entry = this._data.objects[objectId];
|
|
1061
|
+
if (entry) {
|
|
1062
|
+
this.emit('objectUpdated', { objectId, object: entry.data, source });
|
|
1063
|
+
updatedObjects.add(objectId);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
else if (parts[3] === 'links') {
|
|
1068
|
+
// /objects/{objectId}/links/{relation} - links are arrays of target IDs
|
|
1069
|
+
if (parts.length === 5) {
|
|
1070
|
+
const relation = parts[4];
|
|
1071
|
+
if (op.op === 'add' || op.op === 'replace') {
|
|
1072
|
+
// New relation added or replaced - emit linked for all targets in the array
|
|
1073
|
+
const targets = this._data.objects[objectId]?.links[relation] ?? [];
|
|
1074
|
+
for (const targetId of targets) {
|
|
1075
|
+
this.emit('linked', { sourceId: objectId, relation, targetId, source });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
else if (op.op === 'remove') {
|
|
1079
|
+
// Relation removed - we don't have the old targets, so we can't emit individual unlinked events
|
|
1080
|
+
// The targets were already removed from local state by applyPatch before this runs
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
else if (path === '/meta' || path.startsWith('/meta/')) {
|
|
1086
|
+
this.emit('metadataUpdated', { metadata: this._data.meta, source });
|
|
1087
|
+
}
|
|
1088
|
+
// Conversation operations: /conversations/{conversationId} or /conversations/{conversationId}/...
|
|
1089
|
+
else if (path.startsWith('/conversations/')) {
|
|
1090
|
+
const parts = path.split('/');
|
|
1091
|
+
const conversationId = parts[2];
|
|
1092
|
+
if (conversationId) {
|
|
1093
|
+
this.emit('conversationUpdated', { conversationId, source });
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// ===========================================================================
|
|
1099
|
+
// Private Methods
|
|
1100
|
+
// ===========================================================================
|
|
1101
|
+
async resyncFromServer(originalError) {
|
|
1102
|
+
console.warn('[Space] Resyncing from server after sync failure');
|
|
1103
|
+
try {
|
|
1104
|
+
const { data } = await this.graphqlClient.getSpace(this._id);
|
|
1105
|
+
this._data = data;
|
|
1106
|
+
// Clear history is now async but we don't need to wait for it during resync
|
|
1107
|
+
// (it's a server-side cleanup that can happen in background)
|
|
1108
|
+
this.clearHistory().catch((err) => {
|
|
1109
|
+
console.warn('[Space] Failed to clear history during resync:', err);
|
|
1110
|
+
});
|
|
1111
|
+
this.emit('syncError', originalError ?? new Error('Sync failed'));
|
|
1112
|
+
this.emit('reset', { source: 'system' });
|
|
1113
|
+
}
|
|
1114
|
+
catch (error) {
|
|
1115
|
+
console.error('[Space] Failed to resync from server:', error);
|
|
1116
|
+
// Still emit syncError with the original error
|
|
1117
|
+
this.emit('syncError', originalError ?? new Error('Sync failed'));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
//# sourceMappingURL=space.js.map
|