@rool-dev/sdk 0.10.2-dev.47747e3 → 0.10.2-dev.550002

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.
Files changed (51) hide show
  1. package/README.md +36 -163
  2. package/dist/channel.d.ts +41 -129
  3. package/dist/channel.d.ts.map +1 -1
  4. package/dist/channel.js +233 -394
  5. package/dist/channel.js.map +1 -1
  6. package/dist/client.d.ts +3 -55
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +7 -93
  9. package/dist/client.js.map +1 -1
  10. package/dist/graphql.d.ts +4 -46
  11. package/dist/graphql.d.ts.map +1 -1
  12. package/dist/graphql.js +13 -223
  13. package/dist/graphql.js.map +1 -1
  14. package/dist/index.d.ts +3 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -4
  17. package/dist/index.js.map +1 -1
  18. package/dist/path.d.ts +6 -0
  19. package/dist/path.d.ts.map +1 -0
  20. package/dist/path.js +47 -0
  21. package/dist/path.js.map +1 -0
  22. package/dist/rest.d.ts +9 -0
  23. package/dist/rest.d.ts.map +1 -1
  24. package/dist/rest.js +48 -1
  25. package/dist/rest.js.map +1 -1
  26. package/dist/space.d.ts +4 -14
  27. package/dist/space.d.ts.map +1 -1
  28. package/dist/space.js +30 -50
  29. package/dist/space.js.map +1 -1
  30. package/dist/subscription.d.ts.map +1 -1
  31. package/dist/subscription.js +18 -26
  32. package/dist/subscription.js.map +1 -1
  33. package/dist/types.d.ts +36 -212
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/webdav.d.ts +31 -21
  36. package/dist/webdav.d.ts.map +1 -1
  37. package/dist/webdav.js +70 -58
  38. package/dist/webdav.js.map +1 -1
  39. package/package.json +2 -1
  40. package/dist/apps.d.ts +0 -30
  41. package/dist/apps.d.ts.map +0 -1
  42. package/dist/apps.js +0 -81
  43. package/dist/apps.js.map +0 -1
  44. package/dist/locations.d.ts +0 -34
  45. package/dist/locations.d.ts.map +0 -1
  46. package/dist/locations.js +0 -90
  47. package/dist/locations.js.map +0 -1
  48. package/dist/machine.d.ts +0 -16
  49. package/dist/machine.d.ts.map +0 -1
  50. package/dist/machine.js +0 -51
  51. package/dist/machine.js.map +0 -1
package/dist/channel.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { EventEmitter } from './event-emitter.js';
2
- import { generateBasename, loc, normalizeLocation, parseLocation } from './locations.js';
3
- import { resolveMachineResource } from './machine.js';
4
- // 6-character alphanumeric ID — used for interactionIds, conversationIds, etc.
2
+ import { WebDAVError } from './webdav.js';
3
+ import { isObjectPath, machinePath, machineUri } from './path.js';
4
+ // 6-character alphanumeric ID — used for object names, interactionIds, conversationIds, etc.
5
5
  const ENTITY_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
6
+ const GET_OBJECTS_CHUNK_SIZE = 500;
6
7
  export function generateEntityId() {
7
8
  let result = '';
8
9
  for (let i = 0; i < 6; i++) {
@@ -42,6 +43,59 @@ function findDefaultLeaf(interactions) {
42
43
  }
43
44
  return best?.id;
44
45
  }
46
+ function objectPath(input) {
47
+ const path = machinePath(input);
48
+ if (!isObjectPath(path)) {
49
+ throw new Error(`Object path must be /space/<collection>/<name>.json without dotfiles: ${input}`);
50
+ }
51
+ return path;
52
+ }
53
+ function collectionPath(name) {
54
+ return machinePath(`/space/${name}`);
55
+ }
56
+ function schemaPath(name) {
57
+ return `${collectionPath(name)}/.schema.json`;
58
+ }
59
+ function objectFromBody(path, body) {
60
+ return { path, body };
61
+ }
62
+ function jsonObject(value, label) {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
64
+ throw new Error(`${label} must be a JSON object`);
65
+ }
66
+ return value;
67
+ }
68
+ function patchBody(current, patch) {
69
+ const next = { ...current };
70
+ for (const [key, value] of Object.entries(patch)) {
71
+ if (value === null || value === undefined)
72
+ delete next[key];
73
+ else
74
+ next[key] = value;
75
+ }
76
+ return next;
77
+ }
78
+ function collectionDef(input, options) {
79
+ const base = Array.isArray(input)
80
+ ? { fields: input }
81
+ : { fields: input.fields, schemaOrgType: input.schemaOrgType };
82
+ const schemaOrgType = options?.schemaOrgType ?? base.schemaOrgType;
83
+ return schemaOrgType ? { fields: base.fields, schemaOrgType } : { fields: base.fields };
84
+ }
85
+ function normalizeChannel(channel) {
86
+ for (const conversation of Object.values(channel.conversations)) {
87
+ const interactions = conversation.interactions;
88
+ const list = Array.isArray(interactions) ? interactions : Object.values(interactions);
89
+ for (const interaction of list) {
90
+ const wire = interaction;
91
+ if (!wire.modifiedObjectPaths && wire.modifiedObjectLocations) {
92
+ wire.modifiedObjectPaths = wire.modifiedObjectLocations;
93
+ delete wire.modifiedObjectLocations;
94
+ }
95
+ }
96
+ }
97
+ return channel;
98
+ }
45
99
  function attachmentBody(file) {
46
100
  if (isFile(file)) {
47
101
  return {
@@ -115,8 +169,6 @@ function base64Body(data) {
115
169
  bytes[i] = binary.charCodeAt(i);
116
170
  return bytes.buffer;
117
171
  }
118
- // Default timeout for waiting on SSE object events (30 seconds)
119
- const OBJECT_COLLECT_TIMEOUT = 30000;
120
172
  /**
121
173
  * A channel is a space + channelId pair.
122
174
  *
@@ -124,10 +176,10 @@ const OBJECT_COLLECT_TIMEOUT = 30000;
124
176
  * at open time and cannot be changed. To use a different channel,
125
177
  * open a second one.
126
178
  *
127
- * Objects are addressed by location (`/space/<collection>/<basename>.json`).
128
- * Only schema, metadata, the live object location list, and the channel's own
129
- * history are cached locally. Object bodies are fetched on demand. Changes
130
- * arrive via SSE semantic events and are emitted as SDK events.
179
+ * Objects are addressed by machine path (`/space/.../*.json`).
180
+ * Only schema, metadata, object stats, and the channel's own history are cached
181
+ * locally. Object bodies are fetched on demand. Object/file reactivity is
182
+ * exposed at the space level via WebDAV sync notifications.
131
183
  */
132
184
  export class RoolChannel extends EventEmitter {
133
185
  _id;
@@ -143,21 +195,13 @@ export class RoolChannel extends EventEmitter {
143
195
  webdav;
144
196
  onCloseCallback;
145
197
  logger;
146
- // Local cache for bounded data (schema, metadata, own channel, object locations, stats)
198
+ // Local cache for bounded data (schema, metadata, own channel, object stats)
147
199
  _meta;
148
200
  _schema;
149
201
  _channel;
150
- _objectLocations;
151
202
  _objectStats;
152
203
  // Active leaf per conversation (client-side tree cursor)
153
204
  _activeLeaves = new Map();
154
- // Object collection: tracks pending local mutations (by location) for dedup
155
- // Maps location → optimistic object (for create/update) or null (for delete)
156
- _pendingMutations = new Map();
157
- // Resolvers waiting for object data from SSE events, keyed by location
158
- _objectResolvers = new Map();
159
- // Buffer for object data that arrived before a collector was registered, keyed by location
160
- _objectBuffer = new Map();
161
205
  constructor(config) {
162
206
  super();
163
207
  this._id = config.id;
@@ -176,8 +220,7 @@ export class RoolChannel extends EventEmitter {
176
220
  // Initialize local cache from server data
177
221
  this._meta = config.meta;
178
222
  this._schema = config.schema;
179
- this._channel = config.channel;
180
- this._objectLocations = config.objectLocations;
223
+ this._channel = config.channel ? normalizeChannel(config.channel) : undefined;
181
224
  this._objectStats = new Map(Object.entries(config.objectStats));
182
225
  }
183
226
  /**
@@ -198,10 +241,9 @@ export class RoolChannel extends EventEmitter {
198
241
  return;
199
242
  this._meta = data.meta;
200
243
  this._schema = data.schema;
201
- this._objectLocations = data.objectLocations;
202
244
  this._objectStats = new Map(Object.entries(data.objectStats));
203
245
  if (data.channel)
204
- this._channel = data.channel;
246
+ this._channel = normalizeChannel(data.channel);
205
247
  this._activeLeaves.clear();
206
248
  this.emit('reset', { source: 'system' });
207
249
  }
@@ -244,24 +286,6 @@ export class RoolChannel extends EventEmitter {
244
286
  get isReadOnly() {
245
287
  return this._role === 'viewer';
246
288
  }
247
- /**
248
- * Get the extension URL if this channel was created via installExtension, or null.
249
- */
250
- get extensionUrl() {
251
- return this._channel?.extensionUrl ?? null;
252
- }
253
- /**
254
- * Get the extension ID if this channel has an installed extension, or null.
255
- */
256
- get extensionId() {
257
- return this._channel?.extensionId ?? null;
258
- }
259
- /**
260
- * Get the extension manifest if this channel has an installed extension, or null.
261
- */
262
- get manifest() {
263
- return this._channel?.manifest ?? null;
264
- }
265
289
  /**
266
290
  * Get the active branch of the current conversation as a flat array (root → leaf).
267
291
  * Walks from the active leaf up through parentId pointers.
@@ -376,10 +400,6 @@ export class RoolChannel extends EventEmitter {
376
400
  close() {
377
401
  this._closed = true;
378
402
  this.onCloseCallback();
379
- // Clean up pending object collectors
380
- this._objectResolvers.clear();
381
- this._objectBuffer.clear();
382
- this._pendingMutations.clear();
383
403
  this.removeAllListeners();
384
404
  }
385
405
  /**
@@ -413,208 +433,165 @@ export class RoolChannel extends EventEmitter {
413
433
  async clearHistory() {
414
434
  await this.graphqlClient.clearCheckpointHistory(this._id, this._channelId);
415
435
  }
416
- /**
417
- * Get an object by location. Fetches from the server on each call.
418
- *
419
- * Accepts either the canonical form (`/space/<collection>/<basename>.json`)
420
- * or the short form (`<collection>/<basename>`).
421
- */
422
- async getObject(location) {
423
- return this.graphqlClient.getObject(this._id, normalizeLocation(location));
424
- }
425
- /**
426
- * Get an object's stat (audit information).
427
- * Returns the cached stat or undefined if not known.
428
- */
429
- stat(location) {
430
- return this._objectStats.get(normalizeLocation(location));
431
- }
432
- /**
433
- * Find objects using structured filters and/or natural language.
434
- */
435
- async findObjects(options) {
436
- return this._findObjectsImpl(options, this._conversationId);
436
+ davHeaders(conversationId, interactionId) {
437
+ const headers = new Headers({
438
+ 'X-Rool-Channel-Id': this._channelId,
439
+ 'X-Rool-Conversation-Id': conversationId,
440
+ });
441
+ if (interactionId)
442
+ headers.set('X-Rool-Interaction-Id', interactionId);
443
+ return headers;
437
444
  }
438
- /** @internal */
439
- _findObjectsImpl(options, conversationId) {
440
- const normalized = {
441
- ...options,
442
- locations: options.locations?.map(normalizeLocation),
443
- };
444
- return this.graphqlClient.findObjects(this._id, normalized, this._channelId, conversationId);
445
+ async readObject(path) {
446
+ const canonical = objectPath(path);
447
+ try {
448
+ const response = await this.webdav.get(canonical);
449
+ const body = jsonObject(await response.json(), `Object ${canonical}`);
450
+ return { object: objectFromBody(canonical, body), etag: response.headers.get('ETag') };
451
+ }
452
+ catch (error) {
453
+ if (error instanceof WebDAVError && error.status === 404)
454
+ return undefined;
455
+ if (error instanceof SyntaxError)
456
+ throw new Error(`Object ${canonical} did not contain valid JSON`);
457
+ throw error;
458
+ }
445
459
  }
446
- /**
447
- * Get all object locations (sync, from local cache).
448
- * The list is loaded on open and kept current via SSE events.
449
- */
450
- getObjectLocations(options) {
451
- let locs = this._objectLocations;
452
- if (options?.order === 'asc') {
453
- locs = [...locs].reverse();
460
+ /** Get an object JSON file by machine path. Fetches from the server on each call. */
461
+ async getObject(path) {
462
+ return (await this.readObject(path))?.object;
463
+ }
464
+ /** Get object JSON files by machine path in bulk. Duplicate paths are fetched once. */
465
+ async getObjects(paths) {
466
+ const canonical = [];
467
+ const seen = new Set();
468
+ for (const path of paths) {
469
+ const normalized = objectPath(path);
470
+ if (seen.has(normalized))
471
+ continue;
472
+ seen.add(normalized);
473
+ canonical.push(normalized);
454
474
  }
455
- if (options?.limit !== undefined) {
456
- locs = locs.slice(0, options.limit);
475
+ const result = { objects: [], missing: [] };
476
+ for (let i = 0; i < canonical.length; i += GET_OBJECTS_CHUNK_SIZE) {
477
+ const chunk = canonical.slice(i, i + GET_OBJECTS_CHUNK_SIZE);
478
+ const partial = await this.restClient.getObjects(this._id, chunk);
479
+ result.objects.push(...partial.objects);
480
+ result.missing.push(...partial.missing);
457
481
  }
458
- return locs;
482
+ return result;
459
483
  }
460
- /**
461
- * Create a new object in the given collection.
462
- *
463
- * @param collection - The collection (must exist in the schema)
464
- * @param body - Object body fields. Use `{{placeholder}}` for AI-generated content.
465
- * Fields prefixed with `_` are hidden from AI.
466
- * @param options.basename - Specific basename to use. If omitted, the SDK generates a random one.
467
- * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
468
- * @returns The created object and a status message.
469
- */
470
- async createObject(collection, body, options) {
471
- return this._createObjectImpl(collection, body, options, this._conversationId);
484
+ /** Get an object's cached audit information. */
485
+ stat(path) {
486
+ return this._objectStats.get(objectPath(path));
487
+ }
488
+ /** Create or replace an object JSON file at an exact machine path. */
489
+ async putObject(path, body) {
490
+ return this._putObjectImpl(path, body, this._conversationId);
472
491
  }
473
492
  /** @internal */
474
- async _createObjectImpl(collection, body, options, conversationId) {
475
- const basename = options?.basename ?? generateBasename();
476
- const location = loc(collection, basename);
477
- const optimistic = { location, collection, basename, body };
478
- this._pendingMutations.set(location, optimistic);
479
- this.emit('objectCreated', { location, object: optimistic, source: 'local_user' });
493
+ async _putObjectImpl(path, body, conversationId) {
494
+ const canonical = objectPath(path);
495
+ const optimistic = objectFromBody(canonical, body);
480
496
  try {
481
497
  const interactionId = generateEntityId();
482
- const { message, object } = await this.graphqlClient.createObject(this._id, location, body, this._channelId, conversationId, interactionId, { ephemeral: options?.ephemeral, parentInteractionId: options?.parentInteractionId });
483
- const fresh = object ?? await this._collectObject(location);
484
- return { object: fresh, message };
498
+ await this.webdav.put(canonical, JSON.stringify(body), {
499
+ contentType: 'application/json',
500
+ headers: this.davHeaders(conversationId, interactionId),
501
+ });
502
+ const fresh = await this.getObject(canonical) ?? optimistic;
503
+ return { object: fresh, message: `Put ${canonical}` };
485
504
  }
486
505
  catch (error) {
487
- this.logger.error('[RoolChannel] Failed to create object:', error);
488
- this._pendingMutations.delete(location);
489
- this._cancelCollector(location);
506
+ this.logger.error('[RoolChannel] Failed to put object:', error);
490
507
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
491
- this.emit('reset', { source: 'system' });
492
508
  throw error;
493
509
  }
494
510
  }
495
- /**
496
- * Update an existing object.
497
- *
498
- * @param location - The object's location (canonical or short form)
499
- * @param options.data - Fields to add or update. Pass `null` to delete a field. Use `{{placeholder}}` for AI-generated content.
500
- * @param options.prompt - AI prompt to drive the update.
501
- * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
502
- */
503
- async updateObject(location, options) {
504
- return this._updateObjectImpl(location, options, this._conversationId);
511
+ /** Patch an existing object. Null or undefined deletes a field. */
512
+ async patchObject(path, options) {
513
+ return this._patchObjectImpl(path, options, this._conversationId);
505
514
  }
506
515
  /** @internal */
507
- async _updateObjectImpl(location, options, conversationId) {
508
- const canonical = normalizeLocation(location);
509
- const { data } = options;
510
- // Normalize undefined to null (for JSON serialization) and build server patch
511
- let serverPatch;
512
- if (data) {
513
- serverPatch = {};
514
- for (const [key, value] of Object.entries(data)) {
515
- serverPatch[key] = value === undefined ? null : value;
516
- }
517
- }
518
- // Emit optimistic event if we have data changes
519
- if (data) {
520
- const { collection, basename } = parseLocation(canonical);
521
- const optimistic = { location: canonical, collection, basename, body: data };
522
- this._pendingMutations.set(canonical, optimistic);
523
- this.emit('objectUpdated', { location: canonical, object: optimistic, source: 'local_user' });
524
- }
516
+ async _patchObjectImpl(path, options, conversationId) {
517
+ const canonical = objectPath(path);
518
+ const data = options.data ?? {};
519
+ const current = await this.readObject(canonical);
520
+ if (!current)
521
+ throw new Error(`Object ${canonical} not found`);
522
+ const body = patchBody(current.object.body, data);
523
+ const optimistic = objectFromBody(canonical, body);
525
524
  try {
526
525
  const interactionId = generateEntityId();
527
- const { message, object } = await this.graphqlClient.updateObject(this._id, canonical, this._channelId, conversationId, interactionId, {
528
- patch: serverPatch,
529
- prompt: options.prompt,
530
- ephemeral: options.ephemeral,
531
- parentInteractionId: options.parentInteractionId,
526
+ await this.webdav.put(canonical, JSON.stringify(body), {
527
+ contentType: 'application/json',
528
+ ifMatch: current.etag ?? undefined,
529
+ headers: this.davHeaders(conversationId, interactionId),
532
530
  });
533
- const fresh = object ?? await this._collectObject(canonical);
534
- return { object: fresh, message };
531
+ const fresh = await this.getObject(canonical) ?? optimistic;
532
+ return { object: fresh, message: `Patched ${canonical}` };
535
533
  }
536
534
  catch (error) {
537
- this.logger.error('[RoolChannel] Failed to update object:', error);
538
- this._pendingMutations.delete(canonical);
539
- this._cancelCollector(canonical);
535
+ this.logger.error('[RoolChannel] Failed to patch object:', error);
540
536
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
541
- this.emit('reset', { source: 'system' });
542
537
  throw error;
543
538
  }
544
539
  }
545
- /**
546
- * Move (rename or relocate) an object to a new location.
547
- * Use this to rename, change collection, or atomically rewrite the body.
548
- *
549
- * @param from - Current location
550
- * @param to - New location
551
- * @param options.body - Replace the body atomically as part of the move.
552
- * @param options.ephemeral - If true, the operation won't be recorded in interaction history.
553
- */
540
+ /** Move an object JSON file to a new machine path, optionally replacing its body. */
554
541
  async moveObject(from, to, options) {
555
542
  return this._moveObjectImpl(from, to, options, this._conversationId);
556
543
  }
557
544
  /** @internal */
558
545
  async _moveObjectImpl(from, to, options, conversationId) {
559
- const fromLoc = normalizeLocation(from);
560
- const toLoc = normalizeLocation(to);
561
- // Optimistic event emit move so listeners can update keys
562
- const { collection, basename } = parseLocation(toLoc);
563
- const optimistic = {
564
- location: toLoc,
565
- collection,
566
- basename,
567
- body: options?.body ?? {},
568
- };
569
- this._pendingMutations.set(toLoc, optimistic);
570
- this.emit('objectMoved', { from: fromLoc, to: toLoc, object: optimistic, source: 'local_user' });
546
+ const fromPath = objectPath(from);
547
+ const toPath = objectPath(to);
548
+ const optimistic = objectFromBody(toPath, options?.body ?? {});
571
549
  try {
572
550
  const interactionId = generateEntityId();
573
- const { message, object } = await this.graphqlClient.moveObject(this._id, fromLoc, toLoc, this._channelId, conversationId, interactionId, {
574
- body: options?.body,
575
- ephemeral: options?.ephemeral,
576
- parentInteractionId: options?.parentInteractionId,
551
+ await this.webdav.move(fromPath, toPath, {
552
+ headers: this.davHeaders(conversationId, interactionId),
577
553
  });
578
- const fresh = object ?? await this._collectObject(toLoc);
579
- return { object: fresh, message };
554
+ if (options?.body) {
555
+ await this.webdav.put(toPath, JSON.stringify(options.body), {
556
+ contentType: 'application/json',
557
+ headers: this.davHeaders(conversationId, interactionId),
558
+ });
559
+ }
560
+ this._objectStats.delete(fromPath);
561
+ const fresh = await this.getObject(toPath) ?? optimistic;
562
+ return { object: fresh, message: `Moved ${fromPath} to ${toPath}` };
580
563
  }
581
564
  catch (error) {
582
565
  this.logger.error('[RoolChannel] Failed to move object:', error);
583
- this._pendingMutations.delete(toLoc);
584
- this._cancelCollector(toLoc);
585
566
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
586
- this.emit('reset', { source: 'system' });
587
567
  throw error;
588
568
  }
589
569
  }
590
- /**
591
- * Delete objects by location.
592
- * Other objects that reference deleted objects will retain stale ref values.
593
- */
594
- async deleteObjects(locations) {
595
- return this._deleteObjectsImpl(locations, this._conversationId);
570
+ /** Delete object JSON files by machine path. */
571
+ async deleteObjects(paths) {
572
+ return this._deleteObjectsImpl(paths, this._conversationId);
573
+ }
574
+ /** @deprecated Use deleteObjects instead. */
575
+ async deletePaths(paths) {
576
+ return this.deleteObjects(paths);
596
577
  }
597
578
  /** @internal */
598
- async _deleteObjectsImpl(locations, conversationId) {
599
- if (locations.length === 0)
579
+ async _deleteObjectsImpl(paths, conversationId) {
580
+ if (paths.length === 0)
600
581
  return;
601
- const canonical = locations.map(normalizeLocation);
602
- // Track for dedup and emit optimistic events
603
- for (const location of canonical) {
604
- this._pendingMutations.set(location, null);
605
- this.emit('objectDeleted', { location, source: 'local_user' });
606
- }
582
+ const canonical = paths.map(objectPath);
607
583
  try {
608
584
  const interactionId = generateEntityId();
609
- await this.graphqlClient.deleteObjects(this._id, canonical, this._channelId, conversationId, interactionId);
585
+ for (const path of canonical) {
586
+ await this.webdav.delete(path, {
587
+ headers: this.davHeaders(conversationId, interactionId),
588
+ });
589
+ this._objectStats.delete(path);
590
+ }
610
591
  }
611
592
  catch (error) {
612
- this.logger.error('[RoolChannel] Failed to delete objects:', error);
613
- for (const location of canonical) {
614
- this._pendingMutations.delete(location);
615
- }
593
+ this.logger.error('[RoolChannel] Failed to delete paths:', error);
616
594
  this.emit('syncError', error instanceof Error ? error : new Error(String(error)));
617
- this.emit('reset', { source: 'system' });
618
595
  throw error;
619
596
  }
620
597
  }
@@ -623,19 +600,25 @@ export class RoolChannel extends EventEmitter {
623
600
  return this._schema;
624
601
  }
625
602
  /** Create a new collection schema. */
626
- async createCollection(name, fields) {
627
- return this._createCollectionImpl(name, fields, this._conversationId);
603
+ async createCollection(name, fields, options) {
604
+ return this._createCollectionImpl(name, fields, options, this._conversationId);
628
605
  }
629
606
  /** @internal */
630
- async _createCollectionImpl(name, fields, conversationId) {
607
+ async _createCollectionImpl(name, fields, options, conversationId) {
631
608
  if (this._schema[name]) {
632
609
  throw new Error(`Collection "${name}" already exists`);
633
610
  }
634
611
  // Optimistic local update
635
- const optimisticDef = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
612
+ const optimisticDef = collectionDef(fields, options);
636
613
  this._schema[name] = optimisticDef;
637
614
  try {
638
- return await this.graphqlClient.createCollection(this._id, name, fields, this._channelId, conversationId);
615
+ await this.webdav.mkcol(collectionPath(name), { headers: this.davHeaders(conversationId, generateEntityId()) });
616
+ await this.webdav.put(schemaPath(name), JSON.stringify(optimisticDef), {
617
+ contentType: 'application/json',
618
+ headers: this.davHeaders(conversationId, generateEntityId()),
619
+ });
620
+ this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
621
+ return optimisticDef;
639
622
  }
640
623
  catch (error) {
641
624
  this.logger.error('[RoolChannel] Failed to create collection:', error);
@@ -644,18 +627,24 @@ export class RoolChannel extends EventEmitter {
644
627
  }
645
628
  }
646
629
  /** Alter an existing collection schema, replacing its field definitions. */
647
- async alterCollection(name, fields) {
648
- return this._alterCollectionImpl(name, fields, this._conversationId);
630
+ async alterCollection(name, fields, options) {
631
+ return this._alterCollectionImpl(name, fields, options, this._conversationId);
649
632
  }
650
633
  /** @internal */
651
- async _alterCollectionImpl(name, fields, conversationId) {
634
+ async _alterCollectionImpl(name, fields, options, conversationId) {
652
635
  if (!this._schema[name]) {
653
636
  throw new Error(`Collection "${name}" not found`);
654
637
  }
655
638
  const previous = this._schema[name];
656
- this._schema[name] = { fields: fields.map(f => ({ name: f.name, type: f.type })) };
639
+ this._schema[name] = collectionDef(fields, options);
657
640
  try {
658
- return await this.graphqlClient.alterCollection(this._id, name, fields, this._channelId, conversationId);
641
+ const updated = this._schema[name];
642
+ await this.webdav.put(schemaPath(name), JSON.stringify(updated), {
643
+ contentType: 'application/json',
644
+ headers: this.davHeaders(conversationId, generateEntityId()),
645
+ });
646
+ this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
647
+ return updated;
659
648
  }
660
649
  catch (error) {
661
650
  this.logger.error('[RoolChannel] Failed to alter collection:', error);
@@ -675,7 +664,8 @@ export class RoolChannel extends EventEmitter {
675
664
  const previous = this._schema[name];
676
665
  delete this._schema[name];
677
666
  try {
678
- await this.graphqlClient.dropCollection(this._id, name, this._channelId, conversationId);
667
+ await this.webdav.delete(collectionPath(name), { collection: true, headers: this.davHeaders(conversationId, generateEntityId()) });
668
+ this.emit('schemaUpdated', { schema: this._schema, source: 'local_user' });
679
669
  }
680
670
  catch (error) {
681
671
  this.logger.error('[RoolChannel] Failed to drop collection:', error);
@@ -816,8 +806,8 @@ export class RoolChannel extends EventEmitter {
816
806
  let attachmentRefs;
817
807
  if (attachments?.length) {
818
808
  attachmentRefs = await Promise.all(attachments.map(async (attachment) => {
819
- const resource = 'kind' in attachment ? attachment : await this.uploadAttachment(attachment, conversationId);
820
- return `rool-machine:${resource.path.split('/').map(encodeURIComponent).join('/')}`;
809
+ const path = typeof attachment === 'string' ? machinePath(attachment) : await this.uploadAttachment(attachment, conversationId);
810
+ return machineUri(path);
821
811
  }));
822
812
  }
823
813
  // Auto-continue from active leaf if no explicit parent provided
@@ -851,25 +841,11 @@ export class RoolChannel extends EventEmitter {
851
841
  if (onAbort)
852
842
  signal.removeEventListener('abort', onAbort);
853
843
  }
854
- // Collect modified objects — they arrive via SSE events during/after the mutation.
855
844
  const objects = [];
856
- const missing = [];
857
- for (const location of result.modifiedObjectLocations) {
858
- const buffered = this._objectBuffer.get(location);
859
- if (buffered) {
860
- this._objectBuffer.delete(location);
861
- objects.push(buffered);
862
- }
863
- else {
864
- missing.push(location);
865
- }
866
- }
867
- if (missing.length > 0) {
868
- const fetched = await Promise.all(missing.map(location => this.graphqlClient.getObject(this._id, location)));
869
- for (const obj of fetched) {
870
- if (obj)
871
- objects.push(obj);
872
- }
845
+ const fetched = await Promise.all(result.modifiedObjectPaths.map((path) => this.getObject(path)));
846
+ for (const object of fetched) {
847
+ if (object)
848
+ objects.push(object);
873
849
  }
874
850
  return {
875
851
  message: result.message,
@@ -901,70 +877,20 @@ export class RoolChannel extends EventEmitter {
901
877
  return this.restClient.proxyFetch(this._id, url, init);
902
878
  }
903
879
  async uploadAttachment(file, conversationId) {
904
- await this.ensureCollection('attachments');
905
- const directory = `attachments/${conversationId}`;
880
+ await this.ensureCollection('/rool-drive/attachments');
881
+ const directory = `/rool-drive/attachments/${conversationId}`;
906
882
  await this.ensureCollection(directory);
907
883
  const attachment = attachmentBody(file);
908
884
  const path = `${directory}/${attachment.filename}`;
909
885
  await this.webdav.put(path, attachment.body, { contentType: attachment.contentType });
910
- const resource = resolveMachineResource(`/rool-drive/${path}`);
911
- if (!resource)
912
- throw new Error('Failed to resolve uploaded attachment');
913
- return resource;
886
+ return path;
914
887
  }
915
888
  async ensureCollection(path) {
916
- // Note: not an object collection, a folder, which is "collection" in webdav land
917
889
  const response = await this.webdav.request('MKCOL', path, { collection: true });
918
890
  if (response.status === 201 || response.status === 405)
919
891
  return;
920
892
  throw new Error(`Failed to create collection ${path}: ${response.status} ${await response.text()}`);
921
893
  }
922
- /**
923
- * Register a collector that resolves when the object arrives via SSE.
924
- * @internal
925
- */
926
- _collectObject(location) {
927
- return new Promise((resolve, reject) => {
928
- const buffered = this._objectBuffer.get(location);
929
- if (buffered) {
930
- this._objectBuffer.delete(location);
931
- resolve(buffered);
932
- return;
933
- }
934
- const timer = setTimeout(() => {
935
- this._objectResolvers.delete(location);
936
- // Fallback: try to fetch from server
937
- this.graphqlClient.getObject(this._id, location).then(obj => {
938
- if (obj) {
939
- resolve(obj);
940
- }
941
- else {
942
- reject(new Error(`Timeout waiting for object ${location} from SSE`));
943
- }
944
- }).catch(reject);
945
- }, OBJECT_COLLECT_TIMEOUT);
946
- this._objectResolvers.set(location, (obj) => {
947
- clearTimeout(timer);
948
- resolve(obj);
949
- });
950
- });
951
- }
952
- /** @internal */
953
- _cancelCollector(location) {
954
- this._objectResolvers.delete(location);
955
- this._objectBuffer.delete(location);
956
- }
957
- /** @internal */
958
- _deliverObject(location, object) {
959
- const resolver = this._objectResolvers.get(location);
960
- if (resolver) {
961
- resolver(object);
962
- this._objectResolvers.delete(location);
963
- }
964
- else {
965
- this._objectBuffer.set(location, object);
966
- }
967
- }
968
894
  /**
969
895
  * Handle a channel event from the subscription.
970
896
  * @internal
@@ -977,34 +903,6 @@ export class RoolChannel extends EventEmitter {
977
903
  case 'connected':
978
904
  // Resync is handled by the client via _applyResyncData.
979
905
  break;
980
- case 'object_created':
981
- if (event.location && event.object) {
982
- if (event.objectStat)
983
- this._objectStats.set(event.location, event.objectStat);
984
- this._handleObjectCreated(event.location, event.object, changeSource);
985
- }
986
- break;
987
- case 'object_updated':
988
- if (event.location && event.object) {
989
- if (event.objectStat)
990
- this._objectStats.set(event.location, event.objectStat);
991
- this._handleObjectUpdated(event.location, event.object, changeSource);
992
- }
993
- break;
994
- case 'object_deleted':
995
- if (event.location) {
996
- this._objectStats.delete(event.location);
997
- this._handleObjectDeleted(event.location, changeSource);
998
- }
999
- break;
1000
- case 'object_moved':
1001
- if (event.from && event.to && event.object) {
1002
- this._objectStats.delete(event.from);
1003
- if (event.objectStat)
1004
- this._objectStats.set(event.to, event.objectStat);
1005
- this._handleObjectMoved(event.from, event.to, event.object, changeSource);
1006
- }
1007
- break;
1008
906
  case 'schema_updated':
1009
907
  if (event.schema) {
1010
908
  this._schema = event.schema;
@@ -1020,12 +918,19 @@ export class RoolChannel extends EventEmitter {
1020
918
  case 'channel_updated':
1021
919
  if (event.channelId === this._channelId && event.channel) {
1022
920
  const changed = JSON.stringify(this._channel) !== JSON.stringify(event.channel);
1023
- this._channel = event.channel;
921
+ this._channel = normalizeChannel(event.channel);
1024
922
  if (changed) {
1025
923
  this.emit('channelUpdated', { channelId: event.channelId, source: changeSource });
1026
924
  }
1027
925
  }
1028
926
  break;
927
+ case 'channel_deleted':
928
+ if (event.channelId === this._channelId) {
929
+ this._channel = undefined;
930
+ this._activeLeaves.clear();
931
+ this.emit('reset', { source: changeSource });
932
+ }
933
+ break;
1029
934
  case 'conversation_updated':
1030
935
  if (event.channelId === this._channelId && event.conversationId) {
1031
936
  if (!this._channel) {
@@ -1037,7 +942,7 @@ export class RoolChannel extends EventEmitter {
1037
942
  }
1038
943
  const prev = this._channel.conversations[event.conversationId];
1039
944
  if (event.conversation) {
1040
- this._channel.conversations[event.conversationId] = event.conversation;
945
+ this._channel.conversations[event.conversationId] = normalizeChannel({ createdAt: 0, createdBy: '', conversations: { [event.conversationId]: event.conversation } }).conversations[event.conversationId];
1041
946
  }
1042
947
  else {
1043
948
  delete this._channel.conversations[event.conversationId];
@@ -1070,72 +975,6 @@ export class RoolChannel extends EventEmitter {
1070
975
  break;
1071
976
  }
1072
977
  }
1073
- /** @internal */
1074
- _handleObjectCreated(location, object, source) {
1075
- this._deliverObject(location, object);
1076
- // Maintain local location list — prepend (most recently modified first)
1077
- this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1078
- const pending = this._pendingMutations.get(location);
1079
- if (pending !== undefined) {
1080
- this._pendingMutations.delete(location);
1081
- if (pending !== null) {
1082
- // Already emitted objectCreated optimistically.
1083
- // Emit objectUpdated only if AI resolved placeholders (data changed).
1084
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1085
- this.emit('objectUpdated', { location, object, source });
1086
- }
1087
- }
1088
- }
1089
- else {
1090
- this.emit('objectCreated', { location, object, source });
1091
- }
1092
- }
1093
- /** @internal */
1094
- _handleObjectUpdated(location, object, source) {
1095
- this._deliverObject(location, object);
1096
- this._objectLocations = [location, ...this._objectLocations.filter(l => l !== location)];
1097
- const pending = this._pendingMutations.get(location);
1098
- if (pending !== undefined) {
1099
- this._pendingMutations.delete(location);
1100
- if (pending !== null) {
1101
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1102
- this.emit('objectUpdated', { location, object, source });
1103
- }
1104
- }
1105
- }
1106
- else {
1107
- this.emit('objectUpdated', { location, object, source });
1108
- }
1109
- }
1110
- /** @internal */
1111
- _handleObjectDeleted(location, source) {
1112
- this._objectLocations = this._objectLocations.filter(l => l !== location);
1113
- const pending = this._pendingMutations.get(location);
1114
- if (pending !== undefined) {
1115
- this._pendingMutations.delete(location);
1116
- }
1117
- else {
1118
- this.emit('objectDeleted', { location, source });
1119
- }
1120
- }
1121
- /** @internal */
1122
- _handleObjectMoved(from, to, object, source) {
1123
- this._deliverObject(to, object);
1124
- // Drop old location, insert new one at the front.
1125
- this._objectLocations = [to, ...this._objectLocations.filter(l => l !== from && l !== to)];
1126
- const pending = this._pendingMutations.get(to);
1127
- if (pending !== undefined) {
1128
- this._pendingMutations.delete(to);
1129
- if (pending !== null) {
1130
- if (JSON.stringify(pending) !== JSON.stringify(object)) {
1131
- this.emit('objectUpdated', { location: to, object, source });
1132
- }
1133
- }
1134
- }
1135
- else {
1136
- this.emit('objectMoved', { from, to, object, source });
1137
- }
1138
- }
1139
978
  }
1140
979
  /**
1141
980
  * A lightweight handle for a specific conversation within a channel.
@@ -1179,37 +1018,37 @@ export class ConversationHandle {
1179
1018
  async rename(name) {
1180
1019
  return this._channel._renameConversationImpl(name, this._conversationId);
1181
1020
  }
1182
- /** Find objects using structured filters and/or natural language. */
1183
- async findObjects(options) {
1184
- return this._channel._findObjectsImpl(options, this._conversationId);
1185
- }
1186
- /** Create a new object. */
1187
- async createObject(collection, body, options) {
1188
- return this._channel._createObjectImpl(collection, body, options, this._conversationId);
1021
+ /** Create or replace an object JSON file. */
1022
+ async putObject(path, body) {
1023
+ return this._channel._putObjectImpl(path, body, this._conversationId);
1189
1024
  }
1190
- /** Update an existing object. */
1191
- async updateObject(location, options) {
1192
- return this._channel._updateObjectImpl(location, options, this._conversationId);
1025
+ /** Patch an existing object JSON file. */
1026
+ async patchObject(path, options) {
1027
+ return this._channel._patchObjectImpl(path, options, this._conversationId);
1193
1028
  }
1194
1029
  /** Move (rename/relocate) an object. */
1195
1030
  async moveObject(from, to, options) {
1196
1031
  return this._channel._moveObjectImpl(from, to, options, this._conversationId);
1197
1032
  }
1198
- /** Delete objects by location. */
1199
- async deleteObjects(locations) {
1200
- return this._channel._deleteObjectsImpl(locations, this._conversationId);
1033
+ /** Delete object JSON files by path. */
1034
+ async deleteObjects(paths) {
1035
+ return this._channel._deleteObjectsImpl(paths, this._conversationId);
1036
+ }
1037
+ /** @deprecated Use deleteObjects instead. */
1038
+ async deletePaths(paths) {
1039
+ return this.deleteObjects(paths);
1201
1040
  }
1202
1041
  /** Send a prompt to the AI agent, scoped to this conversation's history. */
1203
1042
  async prompt(text, options) {
1204
1043
  return this._channel._promptImpl(text, options, this._conversationId);
1205
1044
  }
1206
1045
  /** Create a new collection schema. */
1207
- async createCollection(name, fields) {
1208
- return this._channel._createCollectionImpl(name, fields, this._conversationId);
1046
+ async createCollection(name, fields, options) {
1047
+ return this._channel._createCollectionImpl(name, fields, options, this._conversationId);
1209
1048
  }
1210
1049
  /** Alter an existing collection schema. */
1211
- async alterCollection(name, fields) {
1212
- return this._channel._alterCollectionImpl(name, fields, this._conversationId);
1050
+ async alterCollection(name, fields, options) {
1051
+ return this._channel._alterCollectionImpl(name, fields, options, this._conversationId);
1213
1052
  }
1214
1053
  /** Drop a collection schema. */
1215
1054
  async dropCollection(name) {