@ably/ai-transport 0.0.1 → 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 +54 -47
- package/dist/ably-ai-transport.js +1006 -539
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +4 -0
- package/dist/core/codec/types.d.ts +19 -2
- package/dist/core/transport/decode-history.d.ts +8 -6
- package/dist/core/transport/headers.d.ts +4 -2
- package/dist/core/transport/index.d.ts +4 -1
- package/dist/core/transport/pipe-stream.d.ts +3 -2
- package/dist/core/transport/stream-router.d.ts +11 -1
- package/dist/core/transport/tree.d.ts +171 -0
- package/dist/core/transport/turn-manager.d.ts +4 -1
- package/dist/core/transport/types.d.ts +270 -119
- package/dist/core/transport/view.d.ts +166 -0
- package/dist/errors.d.ts +19 -2
- package/dist/index.d.ts +3 -1
- package/dist/react/ably-ai-transport-react.js +1019 -486
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/transport-context.d.ts +31 -0
- package/dist/react/contexts/transport-provider.d.ts +49 -0
- package/dist/react/create-transport-hooks.d.ts +124 -0
- package/dist/react/index.d.ts +14 -8
- package/dist/react/use-ably-messages.d.ts +14 -8
- package/dist/react/use-active-turns.d.ts +7 -3
- package/dist/react/use-client-transport.d.ts +78 -5
- package/dist/react/use-create-view.d.ts +22 -0
- package/dist/react/use-tree.d.ts +20 -0
- package/dist/react/use-view.d.ts +79 -0
- package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/tool-transitions.d.ts +50 -0
- package/dist/vercel/index.d.ts +3 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
- package/dist/vercel/react/index.d.ts +5 -0
- package/dist/vercel/react/use-chat-transport.d.ts +61 -20
- package/dist/vercel/react/use-message-sync.d.ts +41 -9
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
- package/dist/vercel/tool-approvals.d.ts +124 -0
- package/dist/vercel/tool-events.d.ts +26 -0
- package/dist/vercel/transport/chat-transport.d.ts +33 -11
- package/dist/vercel/transport/index.d.ts +5 -2
- package/package.json +23 -17
- package/src/constants.ts +6 -0
- package/src/core/codec/encoder.ts +10 -1
- package/src/core/codec/types.ts +19 -3
- package/src/core/transport/client-transport.ts +382 -364
- package/src/core/transport/decode-history.ts +229 -81
- package/src/core/transport/headers.ts +6 -2
- package/src/core/transport/index.ts +13 -5
- package/src/core/transport/pipe-stream.ts +8 -5
- package/src/core/transport/server-transport.ts +212 -58
- package/src/core/transport/stream-router.ts +21 -3
- package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
- package/src/core/transport/turn-manager.ts +28 -10
- package/src/core/transport/types.ts +318 -139
- package/src/core/transport/view.ts +840 -0
- package/src/errors.ts +21 -1
- package/src/index.ts +10 -5
- package/src/react/contexts/transport-context.ts +37 -0
- package/src/react/contexts/transport-provider.tsx +164 -0
- package/src/react/create-transport-hooks.ts +144 -0
- package/src/react/index.ts +15 -8
- package/src/react/use-ably-messages.ts +34 -16
- package/src/react/use-active-turns.ts +28 -17
- package/src/react/use-client-transport.ts +184 -24
- package/src/react/use-create-view.ts +68 -0
- package/src/react/use-tree.ts +53 -0
- package/src/react/use-view.ts +233 -0
- package/src/react/vite.config.ts +4 -1
- package/src/vercel/codec/accumulator.ts +64 -79
- package/src/vercel/codec/decoder.ts +11 -8
- package/src/vercel/codec/encoder.ts +68 -54
- package/src/vercel/codec/index.ts +0 -2
- package/src/vercel/codec/tool-transitions.ts +122 -0
- package/src/vercel/index.ts +17 -0
- package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
- package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
- package/src/vercel/react/index.ts +14 -0
- package/src/vercel/react/use-chat-transport.ts +164 -42
- package/src/vercel/react/use-message-sync.ts +77 -19
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
- package/src/vercel/react/vite.config.ts +4 -2
- package/src/vercel/tool-approvals.ts +380 -0
- package/src/vercel/tool-events.ts +53 -0
- package/src/vercel/transport/chat-transport.ts +225 -79
- package/src/vercel/transport/index.ts +14 -3
- package/dist/core/transport/conversation-tree.d.ts +0 -9
- package/dist/react/use-conversation-tree.d.ts +0 -20
- package/dist/react/use-edit.d.ts +0 -7
- package/dist/react/use-history.d.ts +0 -19
- package/dist/react/use-messages.d.ts +0 -7
- package/dist/react/use-regenerate.d.ts +0 -7
- package/dist/react/use-send.d.ts +0 -7
- package/src/react/use-conversation-tree.ts +0 -71
- package/src/react/use-edit.ts +0 -24
- package/src/react/use-history.ts +0 -111
- package/src/react/use-messages.ts +0 -32
- package/src/react/use-regenerate.ts +0 -24
- package/src/react/use-send.ts +0 -25
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Tree — materializes a branching conversation from a flat
|
|
3
3
|
* oplog of Ably messages using serial-first ordering.
|
|
4
4
|
*
|
|
5
5
|
* Serial order (the total order assigned by Ably) is the primary mechanism
|
|
@@ -9,39 +9,91 @@
|
|
|
9
9
|
*
|
|
10
10
|
* `upsert()` is the sole mutation method. Messages can arrive in any order
|
|
11
11
|
* (live subscription, history pages, seed data) and the tree produces the
|
|
12
|
-
* correct `
|
|
12
|
+
* correct `flattenNodes()` output once all messages are present.
|
|
13
13
|
*
|
|
14
|
-
* The tree owns conversation state. `
|
|
14
|
+
* The tree owns conversation state. `flattenNodes()` returns the linear node
|
|
15
15
|
* list for the currently selected branches — this is what the transport's
|
|
16
16
|
* `getMessages()` delegates to.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import type * as Ably from 'ably';
|
|
20
|
+
|
|
19
21
|
import { HEADER_FORK_OF, HEADER_PARENT } from '../../constants.js';
|
|
22
|
+
import { EventEmitter } from '../../event-emitter.js';
|
|
20
23
|
import type { Logger } from '../../logger.js';
|
|
21
|
-
import type {
|
|
24
|
+
import type { MessageNode, Tree, TurnLifecycleEvent } from './types.js';
|
|
22
25
|
|
|
23
26
|
// ---------------------------------------------------------------------------
|
|
24
27
|
// Internal node type
|
|
25
28
|
// ---------------------------------------------------------------------------
|
|
26
29
|
|
|
27
30
|
interface InternalNode<TMessage> {
|
|
28
|
-
node:
|
|
31
|
+
node: MessageNode<TMessage>;
|
|
29
32
|
/** Insertion sequence — tiebreaker for null-serial messages. */
|
|
30
33
|
insertSeq: number;
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Internal interface — extended surface consumed by View
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Internal tree surface used by View — not part of the public Tree API. */
|
|
41
|
+
export interface TreeInternal<TMessage> extends Tree<TMessage> {
|
|
42
|
+
/**
|
|
43
|
+
* Monotonic counter that increments on structural changes (node insert,
|
|
44
|
+
* delete, serial promotion/reorder) but NOT on content-only updates
|
|
45
|
+
* (existing node's message replaced). Allows the View to skip full
|
|
46
|
+
* tree walks when only message content changed.
|
|
47
|
+
*/
|
|
48
|
+
readonly structuralVersion: number;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Flatten the tree along selected branches into a linear node list.
|
|
52
|
+
* The `selections` map provides the selected sibling's msgId at each
|
|
53
|
+
* fork point, keyed by group root msgId. Fork points not present in
|
|
54
|
+
* the map default to the latest sibling. If a selectedMsgId is not
|
|
55
|
+
* found in the sibling group (stale/deleted), falls back to latest.
|
|
56
|
+
*/
|
|
57
|
+
flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get the "group root" msgId for a sibling group — the original message
|
|
61
|
+
* that all forks in the group trace back to.
|
|
62
|
+
*/
|
|
63
|
+
getGroupRoot(msgId: string): string;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get the sibling group that `msgId` belongs to, as full MessageNode objects.
|
|
67
|
+
* Allows callers to resolve index ↔ msgId without losing identity.
|
|
68
|
+
*/
|
|
69
|
+
getSiblingNodes(msgId: string): MessageNode<TMessage>[];
|
|
70
|
+
|
|
71
|
+
/** Forward a raw Ably message event to tree subscribers. */
|
|
72
|
+
emitAblyMessage(msg: Ably.InboundMessage): void;
|
|
73
|
+
/** Forward a turn lifecycle event to tree subscribers. */
|
|
74
|
+
emitTurn(event: TurnLifecycleEvent): void;
|
|
75
|
+
/** Register an active turn. */
|
|
76
|
+
trackTurn(turnId: string, clientId: string): void;
|
|
77
|
+
/** Unregister an active turn. */
|
|
78
|
+
untrackTurn(turnId: string): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
// ---------------------------------------------------------------------------
|
|
34
82
|
// Implementation
|
|
35
83
|
// ---------------------------------------------------------------------------
|
|
36
84
|
|
|
85
|
+
/** EventEmitter events map for the tree. */
|
|
86
|
+
interface TreeEventsMap {
|
|
87
|
+
update: undefined;
|
|
88
|
+
'ably-message': Ably.InboundMessage;
|
|
89
|
+
turn: TurnLifecycleEvent;
|
|
90
|
+
}
|
|
91
|
+
|
|
37
92
|
// Spec: AIT-CT13
|
|
38
|
-
class
|
|
93
|
+
export class DefaultTree<TMessage> implements TreeInternal<TMessage> {
|
|
39
94
|
/** All nodes indexed by msgId (x-ably-msg-id). */
|
|
40
95
|
private readonly _nodeIndex = new Map<string, InternalNode<TMessage>>();
|
|
41
96
|
|
|
42
|
-
/** Secondary index: codec message key to msgId. Bridges UIMessage.id to x-ably-msg-id. */
|
|
43
|
-
private readonly _codecKeyIndex = new Map<string, string>();
|
|
44
|
-
|
|
45
97
|
/**
|
|
46
98
|
* All nodes sorted by serial (lexicographic). Null-serial messages
|
|
47
99
|
* (optimistic inserts, seed data) sort after all serial-bearing messages,
|
|
@@ -55,21 +107,25 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
55
107
|
*/
|
|
56
108
|
private readonly _parentIndex = new Map<string | undefined, Set<string>>();
|
|
57
109
|
|
|
58
|
-
|
|
59
|
-
* Selected sibling index at each fork point, keyed by the msgId of
|
|
60
|
-
* the first sibling in the group (the fork target). Default: last.
|
|
61
|
-
*/
|
|
62
|
-
private readonly _selections = new Map<string, number>();
|
|
63
|
-
|
|
64
|
-
private readonly _getKey: (message: TMessage) => string;
|
|
110
|
+
private readonly _emitter: EventEmitter<TreeEventsMap>;
|
|
65
111
|
private readonly _logger: Logger;
|
|
66
112
|
|
|
113
|
+
/** Active turns: turnId → clientId. */
|
|
114
|
+
private readonly _turnClientIds = new Map<string, string>();
|
|
115
|
+
|
|
67
116
|
/** Monotonically increasing counter for insertion sequence. */
|
|
68
117
|
private _seqCounter = 0;
|
|
69
118
|
|
|
70
|
-
|
|
71
|
-
|
|
119
|
+
/** Incremented on structural changes; unchanged on content-only updates. */
|
|
120
|
+
private _structuralVersion = 0;
|
|
121
|
+
|
|
122
|
+
get structuralVersion(): number {
|
|
123
|
+
return this._structuralVersion;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
constructor(logger: Logger) {
|
|
72
127
|
this._logger = logger;
|
|
128
|
+
this._emitter = new EventEmitter<TreeEventsMap>(logger);
|
|
73
129
|
}
|
|
74
130
|
|
|
75
131
|
// -------------------------------------------------------------------------
|
|
@@ -171,7 +227,7 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
171
227
|
* @returns The ordered list of sibling nodes.
|
|
172
228
|
*/
|
|
173
229
|
// Spec: AIT-CT13b
|
|
174
|
-
private _getSiblingGroup(msgId: string):
|
|
230
|
+
private _getSiblingGroup(msgId: string): MessageNode<TMessage>[] {
|
|
175
231
|
const entry = this._nodeIndex.get(msgId);
|
|
176
232
|
if (!entry) return [];
|
|
177
233
|
|
|
@@ -218,7 +274,7 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
218
274
|
* @param originalId - The group root to match against.
|
|
219
275
|
* @returns True if the node belongs to the sibling group.
|
|
220
276
|
*/
|
|
221
|
-
private _isSiblingOf(node:
|
|
277
|
+
private _isSiblingOf(node: MessageNode<TMessage>, originalId: string): boolean {
|
|
222
278
|
if (node.msgId === originalId) return true;
|
|
223
279
|
let current = node;
|
|
224
280
|
const visited = new Set<string>([current.msgId]);
|
|
@@ -239,7 +295,7 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
239
295
|
* @param msgId - Any msg-id in the sibling group.
|
|
240
296
|
* @returns The msg-id of the group root.
|
|
241
297
|
*/
|
|
242
|
-
|
|
298
|
+
getGroupRoot(msgId: string): string {
|
|
243
299
|
const entry = this._nodeIndex.get(msgId);
|
|
244
300
|
if (!entry) return msgId;
|
|
245
301
|
|
|
@@ -259,8 +315,9 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
259
315
|
// Public query methods
|
|
260
316
|
// -------------------------------------------------------------------------
|
|
261
317
|
|
|
262
|
-
|
|
263
|
-
|
|
318
|
+
flattenNodes(selections: Map<string, string>): MessageNode<TMessage>[] {
|
|
319
|
+
this._logger.trace('DefaultTree.flattenNodes();');
|
|
320
|
+
const result: MessageNode<TMessage>[] = [];
|
|
264
321
|
const currentPath = new Set<string>();
|
|
265
322
|
// Track which sibling groups we've already resolved to avoid
|
|
266
323
|
// re-resolving for every member of the group.
|
|
@@ -278,14 +335,18 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
278
335
|
// Step 2: Check sibling selection.
|
|
279
336
|
const group = this._getSiblingGroup(msgId);
|
|
280
337
|
if (group.length > 1) {
|
|
281
|
-
const groupRootId = this.
|
|
338
|
+
const groupRootId = this.getGroupRoot(msgId);
|
|
282
339
|
let selectedId = resolvedGroups.get(groupRootId);
|
|
283
340
|
if (selectedId === undefined) {
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
341
|
+
const preferredId = selections.get(groupRootId);
|
|
342
|
+
// Verify the preferred msgId is in the group, otherwise default to latest
|
|
343
|
+
if (preferredId && group.some((n) => n.msgId === preferredId)) {
|
|
344
|
+
selectedId = preferredId;
|
|
345
|
+
} else {
|
|
346
|
+
const latest = group.at(-1);
|
|
347
|
+
if (!latest) break; // unreachable: group.length > 1
|
|
348
|
+
selectedId = latest.msgId;
|
|
349
|
+
}
|
|
289
350
|
resolvedGroups.set(groupRootId, selectedId);
|
|
290
351
|
}
|
|
291
352
|
if (msgId !== selectedId) {
|
|
@@ -294,49 +355,32 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
294
355
|
}
|
|
295
356
|
|
|
296
357
|
currentPath.add(msgId);
|
|
297
|
-
result.push(node
|
|
358
|
+
result.push(node);
|
|
298
359
|
}
|
|
299
360
|
|
|
300
361
|
return result;
|
|
301
362
|
}
|
|
302
363
|
|
|
303
364
|
getSiblings(msgId: string): TMessage[] {
|
|
365
|
+
this._logger.trace('DefaultTree.getSiblings();', { msgId });
|
|
304
366
|
return this._getSiblingGroup(msgId).map((n) => n.message);
|
|
305
367
|
}
|
|
306
368
|
|
|
307
|
-
|
|
308
|
-
return this._getSiblingGroup(msgId)
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
getSelectedIndex(msgId: string): number {
|
|
312
|
-
const group = this._getSiblingGroup(msgId);
|
|
313
|
-
if (group.length <= 1) return 0;
|
|
314
|
-
const groupRootId = this._getGroupRoot(msgId);
|
|
315
|
-
const stored = this._selections.get(groupRootId);
|
|
316
|
-
if (stored !== undefined) return Math.max(0, Math.min(stored, group.length - 1));
|
|
317
|
-
return group.length - 1; // default: latest
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Spec: AIT-CT13c
|
|
321
|
-
select(msgId: string, index: number): void {
|
|
322
|
-
this._logger.debug('ConversationTree.select();', { msgId, index });
|
|
323
|
-
const group = this._getSiblingGroup(msgId);
|
|
324
|
-
if (group.length <= 1) return;
|
|
325
|
-
const groupRootId = this._getGroupRoot(msgId);
|
|
326
|
-
this._selections.set(groupRootId, Math.max(0, Math.min(index, group.length - 1)));
|
|
369
|
+
getSiblingNodes(msgId: string): MessageNode<TMessage>[] {
|
|
370
|
+
return this._getSiblingGroup(msgId);
|
|
327
371
|
}
|
|
328
372
|
|
|
329
|
-
|
|
330
|
-
return this.
|
|
373
|
+
hasSiblings(msgId: string): boolean {
|
|
374
|
+
return this._getSiblingGroup(msgId).length > 1;
|
|
331
375
|
}
|
|
332
376
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (!msgId) return undefined;
|
|
377
|
+
getNode(msgId: string): MessageNode<TMessage> | undefined {
|
|
378
|
+
this._logger.trace('DefaultTree.getNode();', { msgId });
|
|
336
379
|
return this._nodeIndex.get(msgId)?.node;
|
|
337
380
|
}
|
|
338
381
|
|
|
339
382
|
getHeaders(msgId: string): Record<string, string> | undefined {
|
|
383
|
+
this._logger.trace('DefaultTree.getHeaders();', { msgId });
|
|
340
384
|
return this._nodeIndex.get(msgId)?.node.headers;
|
|
341
385
|
}
|
|
342
386
|
|
|
@@ -348,9 +392,6 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
348
392
|
const parentId = headers[HEADER_PARENT] ?? undefined;
|
|
349
393
|
const forkOf = headers[HEADER_FORK_OF] ?? undefined;
|
|
350
394
|
|
|
351
|
-
// Maintain codec key → msgId secondary index
|
|
352
|
-
this._codecKeyIndex.set(this._getKey(message), msgId);
|
|
353
|
-
|
|
354
395
|
const existing = this._nodeIndex.get(msgId);
|
|
355
396
|
if (existing) {
|
|
356
397
|
// Update in place — message content may have changed (e.g. streaming).
|
|
@@ -363,18 +404,21 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
363
404
|
// Spec: AIT-CT13d
|
|
364
405
|
// Promote serial: optimistic (null) → server-assigned on relay.
|
|
365
406
|
if (serial && !existing.node.serial) {
|
|
366
|
-
this._logger.debug('
|
|
407
|
+
this._logger.debug('Tree.upsert(); promoting serial', { msgId, serial });
|
|
367
408
|
existing.node.serial = serial;
|
|
368
409
|
// Re-sort: remove from current position, re-insert at correct position.
|
|
369
410
|
this._removeSorted(existing);
|
|
370
411
|
this._insertSorted(existing);
|
|
412
|
+
this._structuralVersion++;
|
|
371
413
|
}
|
|
414
|
+
this._emitter.emit('update');
|
|
372
415
|
return;
|
|
373
416
|
}
|
|
374
417
|
|
|
375
|
-
this._logger.trace('
|
|
418
|
+
this._logger.trace('Tree.upsert(); inserting new node', { msgId, parentId, forkOf });
|
|
376
419
|
|
|
377
|
-
const node:
|
|
420
|
+
const node: MessageNode<TMessage> = {
|
|
421
|
+
kind: 'message',
|
|
378
422
|
message,
|
|
379
423
|
msgId,
|
|
380
424
|
parentId,
|
|
@@ -387,22 +431,18 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
387
431
|
this._nodeIndex.set(msgId, internal);
|
|
388
432
|
this._addToParentIndex(parentId, msgId);
|
|
389
433
|
this._insertSorted(internal);
|
|
434
|
+
this._structuralVersion++;
|
|
435
|
+
this._emitter.emit('update');
|
|
390
436
|
}
|
|
391
437
|
|
|
392
438
|
delete(msgId: string): void {
|
|
393
439
|
const entry = this._nodeIndex.get(msgId);
|
|
394
440
|
if (!entry) return;
|
|
395
441
|
|
|
396
|
-
this._logger.debug('
|
|
442
|
+
this._logger.debug('Tree.delete();', { msgId });
|
|
397
443
|
|
|
398
444
|
const { node } = entry;
|
|
399
445
|
|
|
400
|
-
// Clean up secondary index
|
|
401
|
-
const codecKey = this._getKey(node.message);
|
|
402
|
-
if (this._codecKeyIndex.get(codecKey) === msgId) {
|
|
403
|
-
this._codecKeyIndex.delete(codecKey);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
446
|
// Remove from parent index
|
|
407
447
|
this._removeFromParentIndex(node.parentId, msgId);
|
|
408
448
|
|
|
@@ -411,10 +451,87 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
411
451
|
|
|
412
452
|
// Remove from primary index
|
|
413
453
|
this._nodeIndex.delete(msgId);
|
|
414
|
-
this._selections.delete(msgId);
|
|
415
454
|
|
|
416
|
-
// Children are NOT deleted — they become unreachable in
|
|
455
|
+
// Children are NOT deleted — they become unreachable in flattenNodes()
|
|
417
456
|
// because their parent is no longer on the active path.
|
|
457
|
+
this._structuralVersion++;
|
|
458
|
+
this._emitter.emit('update');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// -------------------------------------------------------------------------
|
|
462
|
+
// Events
|
|
463
|
+
// -------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
// Spec: AIT-CT17
|
|
466
|
+
getActiveTurnIds(): Map<string, Set<string>> {
|
|
467
|
+
this._logger.trace('DefaultTree.getActiveTurnIds();');
|
|
468
|
+
const result = new Map<string, Set<string>>();
|
|
469
|
+
for (const [turnId, clientId] of this._turnClientIds) {
|
|
470
|
+
let set = result.get(clientId);
|
|
471
|
+
if (!set) {
|
|
472
|
+
set = new Set<string>();
|
|
473
|
+
result.set(clientId, set);
|
|
474
|
+
}
|
|
475
|
+
set.add(turnId);
|
|
476
|
+
}
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Spec: AIT-CT8b, AIT-CT8e
|
|
481
|
+
on(event: 'update', handler: () => void): () => void;
|
|
482
|
+
on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
|
|
483
|
+
on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
|
|
484
|
+
on(
|
|
485
|
+
event: 'update' | 'ably-message' | 'turn',
|
|
486
|
+
handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
|
|
487
|
+
): () => void {
|
|
488
|
+
// CAST: overload signatures enforce correct handler types per event name.
|
|
489
|
+
const cb = handler as (arg: TreeEventsMap[keyof TreeEventsMap]) => void;
|
|
490
|
+
this._emitter.on(event, cb);
|
|
491
|
+
return () => {
|
|
492
|
+
this._emitter.off(event, cb);
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// -------------------------------------------------------------------------
|
|
497
|
+
// Internal methods (called by the transport, not part of Tree interface)
|
|
498
|
+
// -------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Forward a raw Ably message event to tree subscribers.
|
|
502
|
+
* @param msg - The raw Ably message to emit.
|
|
503
|
+
*/
|
|
504
|
+
emitAblyMessage(msg: Ably.InboundMessage): void {
|
|
505
|
+
this._logger.trace('DefaultTree.emitAblyMessage();');
|
|
506
|
+
this._emitter.emit('ably-message', msg);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Forward a turn lifecycle event to tree subscribers.
|
|
511
|
+
* @param event - The turn lifecycle event to emit.
|
|
512
|
+
*/
|
|
513
|
+
emitTurn(event: TurnLifecycleEvent): void {
|
|
514
|
+
this._logger.trace('DefaultTree.emitTurn();', { turnId: event.turnId });
|
|
515
|
+
this._emitter.emit('turn', event);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Register an active turn.
|
|
520
|
+
* @param turnId - The turn's unique identifier.
|
|
521
|
+
* @param clientId - The client that owns the turn.
|
|
522
|
+
*/
|
|
523
|
+
trackTurn(turnId: string, clientId: string): void {
|
|
524
|
+
this._logger.trace('DefaultTree.trackTurn();', { turnId, clientId });
|
|
525
|
+
this._turnClientIds.set(turnId, clientId);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Unregister an active turn.
|
|
530
|
+
* @param turnId - The turn to untrack.
|
|
531
|
+
*/
|
|
532
|
+
untrackTurn(turnId: string): void {
|
|
533
|
+
this._logger.trace('DefaultTree.untrackTurn();', { turnId });
|
|
534
|
+
this._turnClientIds.delete(turnId);
|
|
418
535
|
}
|
|
419
536
|
}
|
|
420
537
|
|
|
@@ -423,12 +540,10 @@ class DefaultConversationTree<TMessage> implements ConversationTree<TMessage> {
|
|
|
423
540
|
// ---------------------------------------------------------------------------
|
|
424
541
|
|
|
425
542
|
/**
|
|
426
|
-
* Create a
|
|
427
|
-
* @param getKey - Codec function that returns a stable key for a domain message.
|
|
543
|
+
* Create a Tree that materializes branching history from a flat oplog.
|
|
428
544
|
* @param logger - Logger for diagnostic output.
|
|
429
|
-
* @returns A new {@link
|
|
545
|
+
* @returns A new {@link DefaultTree} instance. The transport uses DefaultTree
|
|
546
|
+
* directly for internal methods (emitAblyMessage, emitTurn, trackTurn, untrackTurn).
|
|
547
|
+
* Public consumers see the narrower {@link Tree} interface.
|
|
430
548
|
*/
|
|
431
|
-
export const
|
|
432
|
-
getKey: (message: TMessage) => string,
|
|
433
|
-
logger: Logger,
|
|
434
|
-
): ConversationTree<TMessage> => new DefaultConversationTree(getKey, logger);
|
|
549
|
+
export const createTree = <TMessage>(logger: Logger): DefaultTree<TMessage> => new DefaultTree(logger);
|
|
@@ -11,6 +11,8 @@ import type * as Ably from 'ably';
|
|
|
11
11
|
import {
|
|
12
12
|
EVENT_TURN_END,
|
|
13
13
|
EVENT_TURN_START,
|
|
14
|
+
HEADER_FORK_OF,
|
|
15
|
+
HEADER_PARENT,
|
|
14
16
|
HEADER_TURN_CLIENT_ID,
|
|
15
17
|
HEADER_TURN_ID,
|
|
16
18
|
HEADER_TURN_REASON,
|
|
@@ -25,7 +27,12 @@ import type { TurnEndReason } from './types.js';
|
|
|
25
27
|
/** Manages active turns and publishes turn lifecycle events on the channel. */
|
|
26
28
|
export interface TurnManager {
|
|
27
29
|
/** Register a new turn. Publishes turn-start on the channel. Returns AbortSignal. */
|
|
28
|
-
startTurn(
|
|
30
|
+
startTurn(
|
|
31
|
+
turnId: string,
|
|
32
|
+
clientId?: string,
|
|
33
|
+
controller?: AbortController,
|
|
34
|
+
metadata?: { parent?: string; forkOf?: string },
|
|
35
|
+
): Promise<AbortSignal>;
|
|
29
36
|
/** End a turn. Publishes turn-end on the channel. Cleans up internal state. */
|
|
30
37
|
endTurn(turnId: string, reason: TurnEndReason): Promise<void>;
|
|
31
38
|
/** Get the AbortSignal for a turn. */
|
|
@@ -44,7 +51,7 @@ export interface TurnManager {
|
|
|
44
51
|
// Internal state
|
|
45
52
|
// ---------------------------------------------------------------------------
|
|
46
53
|
|
|
47
|
-
interface
|
|
54
|
+
interface ActiveTurnsEntry {
|
|
48
55
|
controller: AbortController;
|
|
49
56
|
clientId: string;
|
|
50
57
|
}
|
|
@@ -56,28 +63,39 @@ interface TurnState {
|
|
|
56
63
|
class DefaultTurnManager implements TurnManager {
|
|
57
64
|
private readonly _channel: Ably.RealtimeChannel;
|
|
58
65
|
private readonly _logger: Logger | undefined;
|
|
59
|
-
private readonly _activeTurns = new Map<string,
|
|
66
|
+
private readonly _activeTurns = new Map<string, ActiveTurnsEntry>();
|
|
60
67
|
|
|
61
68
|
constructor(channel: Ably.RealtimeChannel, logger?: Logger) {
|
|
62
69
|
this._channel = channel;
|
|
63
70
|
this._logger = logger?.withContext({ component: 'TurnManager' });
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
async startTurn(
|
|
73
|
+
async startTurn(
|
|
74
|
+
turnId: string,
|
|
75
|
+
clientId?: string,
|
|
76
|
+
externalController?: AbortController,
|
|
77
|
+
metadata?: { parent?: string; forkOf?: string },
|
|
78
|
+
): Promise<AbortSignal> {
|
|
67
79
|
this._logger?.trace('DefaultTurnManager.startTurn();', { turnId, clientId });
|
|
68
80
|
|
|
69
81
|
const controller = externalController ?? new AbortController();
|
|
70
82
|
const resolvedClientId = clientId ?? '';
|
|
71
83
|
this._activeTurns.set(turnId, { controller, clientId: resolvedClientId });
|
|
72
84
|
|
|
85
|
+
const headers: Record<string, string> = {
|
|
86
|
+
[HEADER_TURN_ID]: turnId,
|
|
87
|
+
[HEADER_TURN_CLIENT_ID]: resolvedClientId,
|
|
88
|
+
};
|
|
89
|
+
if (metadata?.parent !== undefined) {
|
|
90
|
+
headers[HEADER_PARENT] = metadata.parent;
|
|
91
|
+
}
|
|
92
|
+
if (metadata?.forkOf !== undefined) {
|
|
93
|
+
headers[HEADER_FORK_OF] = metadata.forkOf;
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
await this._channel.publish({
|
|
74
97
|
name: EVENT_TURN_START,
|
|
75
|
-
extras: {
|
|
76
|
-
headers: {
|
|
77
|
-
[HEADER_TURN_ID]: turnId,
|
|
78
|
-
[HEADER_TURN_CLIENT_ID]: resolvedClientId,
|
|
79
|
-
},
|
|
80
|
-
},
|
|
98
|
+
extras: { headers },
|
|
81
99
|
});
|
|
82
100
|
|
|
83
101
|
this._logger?.debug('DefaultTurnManager.startTurn(); turn started', { turnId });
|