@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
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DefaultView — a paginated, branch-aware projection over the Tree.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a Tree and manages a pagination window that controls which nodes
|
|
5
|
+
* are visible to the UI. New live messages appear immediately; older messages
|
|
6
|
+
* are revealed progressively via `loadOlder()`.
|
|
7
|
+
*
|
|
8
|
+
* Each View owns its own branch selection state and pagination window,
|
|
9
|
+
* allowing multiple independent Views over the same Tree.
|
|
10
|
+
*
|
|
11
|
+
* Events are scoped to the visible window — 'update' only fires when the
|
|
12
|
+
* visible output changes, 'ably-message' only for messages corresponding to
|
|
13
|
+
* visible nodes, and 'turn' only for turns with visible messages.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as Ably from 'ably';
|
|
17
|
+
|
|
18
|
+
import { EVENT_TURN_END, EVENT_TURN_START, HEADER_MSG_ID, HEADER_TURN_ID } from '../../constants.js';
|
|
19
|
+
import { ErrorCode } from '../../errors.js';
|
|
20
|
+
import { EventEmitter } from '../../event-emitter.js';
|
|
21
|
+
import type { Logger } from '../../logger.js';
|
|
22
|
+
import { getHeaders } from '../../utils.js';
|
|
23
|
+
import type { Codec } from '../codec/types.js';
|
|
24
|
+
import { decodeHistory } from './decode-history.js';
|
|
25
|
+
import type { TreeInternal } from './tree.js';
|
|
26
|
+
import type {
|
|
27
|
+
ActiveTurn,
|
|
28
|
+
EventsNode,
|
|
29
|
+
HistoryPage,
|
|
30
|
+
MessageNode,
|
|
31
|
+
SendOptions,
|
|
32
|
+
TurnLifecycleEvent,
|
|
33
|
+
View,
|
|
34
|
+
} from './types.js';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Events map
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
interface ViewEventsMap {
|
|
41
|
+
update: undefined;
|
|
42
|
+
'ably-message': Ably.InboundMessage;
|
|
43
|
+
turn: TurnLifecycleEvent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Send delegate
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Internal delegate function provided by the transport for executing sends.
|
|
52
|
+
* The View pre-computes the visible branch history and passes it directly,
|
|
53
|
+
* so the delegate has no back-reference to the View.
|
|
54
|
+
* When `eventNodes` is provided, the transport includes them in the POST body
|
|
55
|
+
* for the server to publish as cross-turn events.
|
|
56
|
+
*/
|
|
57
|
+
export type SendDelegate<TEvent, TMessage> = (
|
|
58
|
+
input: TMessage | TMessage[],
|
|
59
|
+
options: SendOptions | undefined,
|
|
60
|
+
history: MessageNode<TMessage>[],
|
|
61
|
+
eventNodes?: EventsNode<TEvent>[],
|
|
62
|
+
) => Promise<ActiveTurn<TEvent>>;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Options
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** Options for creating a View. */
|
|
69
|
+
export interface ViewOptions<TEvent, TMessage> {
|
|
70
|
+
/** The tree to project. */
|
|
71
|
+
tree: TreeInternal<TMessage>;
|
|
72
|
+
/** The Ably channel to load history from. */
|
|
73
|
+
channel: Ably.RealtimeChannel;
|
|
74
|
+
/** The codec for decoding history messages. */
|
|
75
|
+
codec: Codec<TEvent, TMessage>;
|
|
76
|
+
/** Delegate for executing sends through the transport. */
|
|
77
|
+
sendDelegate: SendDelegate<TEvent, TMessage>;
|
|
78
|
+
/** Logger for diagnostic output. */
|
|
79
|
+
logger: Logger;
|
|
80
|
+
/** Called when the view is closed, allowing the owner to clean up references. */
|
|
81
|
+
onClose?: () => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Branch selection
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Tagged union representing why a branch was selected.
|
|
90
|
+
* Stored per group root in the View's `_branchSelections` map.
|
|
91
|
+
*/
|
|
92
|
+
type BranchSelection =
|
|
93
|
+
/** Explicit navigation via `select()`. */
|
|
94
|
+
| { kind: 'user'; selectedId: string }
|
|
95
|
+
/** This view initiated a fork (edit or regenerate) — auto-selected the result. */
|
|
96
|
+
| { kind: 'auto'; selectedId: string }
|
|
97
|
+
/** An external fork appeared — pinned to the currently-visible sibling to prevent drift. */
|
|
98
|
+
| { kind: 'pinned'; selectedId: string }
|
|
99
|
+
/** This view's `regenerate()` is in flight — select newest when turn's response arrives. */
|
|
100
|
+
| { kind: 'pending'; turnId: string };
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Implementation
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
export class DefaultView<TEvent, TMessage> implements View<TEvent, TMessage> {
|
|
107
|
+
private readonly _tree: TreeInternal<TMessage>;
|
|
108
|
+
private readonly _channel: Ably.RealtimeChannel;
|
|
109
|
+
private readonly _codec: Codec<TEvent, TMessage>;
|
|
110
|
+
private readonly _sendDelegate: SendDelegate<TEvent, TMessage>;
|
|
111
|
+
private readonly _logger: Logger;
|
|
112
|
+
private readonly _emitter: EventEmitter<ViewEventsMap>;
|
|
113
|
+
private readonly _onClose?: () => void;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* View-local branch selections: group root msgId → selection intent.
|
|
117
|
+
* Fork points not present here default to the latest sibling.
|
|
118
|
+
* Replaces the previous numeric-index _selections and _pendingForkSelections
|
|
119
|
+
* with a single tagged-union map that carries the selected msgId (not index)
|
|
120
|
+
* and the reason for the selection.
|
|
121
|
+
*/
|
|
122
|
+
private readonly _branchSelections = new Map<string, BranchSelection>();
|
|
123
|
+
|
|
124
|
+
/** Spec: AIT-CT11c — msg-ids loaded from history but not yet revealed to the UI. */
|
|
125
|
+
private readonly _withheldMsgIds = new Set<string>();
|
|
126
|
+
|
|
127
|
+
/** Snapshot of visible msgIds — used to detect structural changes and for selection pinning. */
|
|
128
|
+
private _lastVisibleIds: string[] = [];
|
|
129
|
+
|
|
130
|
+
/** Snapshot of visible message references — used to detect in-place content updates (streaming). */
|
|
131
|
+
private _lastVisibleMessages: TMessage[] = [];
|
|
132
|
+
|
|
133
|
+
/** Cached set of turn IDs present on the visible branch — avoids recomputing flattenNodes() on turn events. */
|
|
134
|
+
private _lastVisibleTurnIds = new Set<string>();
|
|
135
|
+
|
|
136
|
+
/** Whether there are more history pages to fetch from the channel. */
|
|
137
|
+
private _hasMoreHistory = false;
|
|
138
|
+
|
|
139
|
+
/** Internal state for continuing history pagination. */
|
|
140
|
+
private _lastHistoryPage: HistoryPage<TMessage> | undefined;
|
|
141
|
+
|
|
142
|
+
/** Buffer of withheld nodes, drained newest-first by successive loadOlder() calls. */
|
|
143
|
+
private readonly _withheldBuffer: MessageNode<TMessage>[] = [];
|
|
144
|
+
|
|
145
|
+
/** Unsubscribe functions for tree event subscriptions. */
|
|
146
|
+
private readonly _unsubs: (() => void)[] = [];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Cached result of the last flattenNodes computation. Public `flattenNodes()`
|
|
150
|
+
* returns this in O(1); internal callers use `_computeFlatNodes()` when a
|
|
151
|
+
* fresh tree walk is needed (structural changes, selection changes, history reveal).
|
|
152
|
+
*/
|
|
153
|
+
private _cachedNodes: MessageNode<TMessage>[] = [];
|
|
154
|
+
|
|
155
|
+
/** Last seen tree structural version - used to distinguish content-only from structural updates. */
|
|
156
|
+
private _lastStructuralVersion = -1;
|
|
157
|
+
|
|
158
|
+
private _loadingOlder = false;
|
|
159
|
+
private _processingHistory = false;
|
|
160
|
+
private _closed = false;
|
|
161
|
+
|
|
162
|
+
constructor(options: ViewOptions<TEvent, TMessage>) {
|
|
163
|
+
this._tree = options.tree;
|
|
164
|
+
this._channel = options.channel;
|
|
165
|
+
this._codec = options.codec;
|
|
166
|
+
this._sendDelegate = options.sendDelegate;
|
|
167
|
+
this._onClose = options.onClose;
|
|
168
|
+
this._logger = options.logger.withContext({ component: 'View' });
|
|
169
|
+
this._logger.trace('DefaultView();');
|
|
170
|
+
this._emitter = new EventEmitter<ViewEventsMap>(this._logger);
|
|
171
|
+
|
|
172
|
+
// Compute initial cache and snapshot visible state
|
|
173
|
+
this._cachedNodes = this._computeFlatNodes();
|
|
174
|
+
this._lastStructuralVersion = this._tree.structuralVersion;
|
|
175
|
+
this._updateVisibleSnapshot(this._cachedNodes);
|
|
176
|
+
|
|
177
|
+
// Subscribe to tree events and re-emit scoped versions
|
|
178
|
+
this._unsubs.push(
|
|
179
|
+
this._tree.on('update', () => {
|
|
180
|
+
this._onTreeUpdate();
|
|
181
|
+
}),
|
|
182
|
+
this._tree.on('ably-message', (msg) => {
|
|
183
|
+
this._onTreeAblyMessage(msg);
|
|
184
|
+
}),
|
|
185
|
+
this._tree.on('turn', (event) => {
|
|
186
|
+
this._onTreeTurn(event);
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
// Public query methods
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
getMessages(): TMessage[] {
|
|
196
|
+
return this.flattenNodes().map((n) => n.message);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Spec: AIT-CT9, AIT-CT11c
|
|
200
|
+
flattenNodes(): MessageNode<TMessage>[] {
|
|
201
|
+
return this._cachedNodes;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Walk the tree and compute a fresh visible node list, applying branch
|
|
206
|
+
* selections and withheld-message filtering. Use this instead of the
|
|
207
|
+
* public `flattenNodes()` when the cache may be stale (structural
|
|
208
|
+
* changes, selection changes, history reveal).
|
|
209
|
+
* @returns A fresh array of visible nodes.
|
|
210
|
+
*/
|
|
211
|
+
private _computeFlatNodes(): MessageNode<TMessage>[] {
|
|
212
|
+
const nodes = this._tree.flattenNodes(this._resolveSelections());
|
|
213
|
+
if (this._withheldMsgIds.size === 0) return nodes;
|
|
214
|
+
return nodes.filter((n) => !this._withheldMsgIds.has(n.msgId));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
hasOlder(): boolean {
|
|
218
|
+
return this._withheldBuffer.length > 0 || this._hasMoreHistory;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async loadOlder(limit = 100): Promise<void> {
|
|
222
|
+
if (this._closed || this._loadingOlder) return;
|
|
223
|
+
this._loadingOlder = true;
|
|
224
|
+
this._logger.trace('DefaultView.loadOlder();', { limit });
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// Drain withheld buffer first (older messages, released newest-first)
|
|
228
|
+
if (this._withheldBuffer.length > 0) {
|
|
229
|
+
const batch = this._withheldBuffer.splice(-limit, limit);
|
|
230
|
+
this._releaseWithheld(batch);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Buffer exhausted — load from channel history
|
|
235
|
+
if (!this._hasMoreHistory && !this._lastHistoryPage) {
|
|
236
|
+
// First load
|
|
237
|
+
await this._loadFirstPage(limit);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!this._hasMoreHistory) return;
|
|
242
|
+
|
|
243
|
+
// Continue from last page
|
|
244
|
+
if (!this._lastHistoryPage?.hasNext()) {
|
|
245
|
+
this._hasMoreHistory = false;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const nextPage = await this._lastHistoryPage.next();
|
|
250
|
+
// Re-check: close() may be called during the await from another call stack
|
|
251
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
|
|
252
|
+
if (this._closed || !nextPage) {
|
|
253
|
+
if (!nextPage) this._hasMoreHistory = false;
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
await this._loadAndReveal(nextPage, limit);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
this._logger.error('DefaultView.loadOlder(); failed', { error });
|
|
260
|
+
throw error;
|
|
261
|
+
} finally {
|
|
262
|
+
this._loadingOlder = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// -------------------------------------------------------------------------
|
|
267
|
+
// Branch navigation
|
|
268
|
+
// -------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
// Spec: AIT-CT13c
|
|
271
|
+
select(msgId: string, index: number): void {
|
|
272
|
+
this._logger.trace('DefaultView.select();', { msgId, index });
|
|
273
|
+
const nodes = this._tree.getSiblingNodes(msgId);
|
|
274
|
+
if (nodes.length <= 1) return;
|
|
275
|
+
const groupRootId = this._tree.getGroupRoot(msgId);
|
|
276
|
+
const clamped = Math.max(0, Math.min(index, nodes.length - 1));
|
|
277
|
+
const selected = nodes[clamped];
|
|
278
|
+
if (!selected) return; // unreachable: clamped is always in bounds
|
|
279
|
+
this._branchSelections.set(groupRootId, { kind: 'user', selectedId: selected.msgId });
|
|
280
|
+
this._logger.debug('DefaultView.select();', { msgId, index: clamped, selectedId: selected.msgId });
|
|
281
|
+
this._cachedNodes = this._computeFlatNodes();
|
|
282
|
+
this._updateVisibleSnapshot(this._cachedNodes);
|
|
283
|
+
this._emitter.emit('update');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
getSelectedIndex(msgId: string): number {
|
|
287
|
+
this._logger.trace('DefaultView.getSelectedIndex();', { msgId });
|
|
288
|
+
const nodes = this._tree.getSiblingNodes(msgId);
|
|
289
|
+
if (nodes.length <= 1) return 0;
|
|
290
|
+
const groupRootId = this._tree.getGroupRoot(msgId);
|
|
291
|
+
const sel = this._branchSelections.get(groupRootId);
|
|
292
|
+
if (!sel || sel.kind === 'pending') return nodes.length - 1; // default: latest
|
|
293
|
+
const idx = nodes.findIndex((n) => n.msgId === sel.selectedId);
|
|
294
|
+
if (idx === -1) return nodes.length - 1; // fallback if stale
|
|
295
|
+
return idx;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
getSiblings(msgId: string): TMessage[] {
|
|
299
|
+
return this._tree.getSiblings(msgId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
hasSiblings(msgId: string): boolean {
|
|
303
|
+
return this._tree.hasSiblings(msgId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
getNode(msgId: string): MessageNode<TMessage> | undefined {
|
|
307
|
+
return this._tree.getNode(msgId);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// -------------------------------------------------------------------------
|
|
311
|
+
// Write operations
|
|
312
|
+
// -------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
// Spec: AIT-CT3, AIT-CT4
|
|
315
|
+
async send(input: TMessage | TMessage[], options?: SendOptions): Promise<ActiveTurn<TEvent>> {
|
|
316
|
+
this._logger.trace('DefaultView.send();');
|
|
317
|
+
if (this._closed) {
|
|
318
|
+
throw new Ably.ErrorInfo('unable to send; view is closed', ErrorCode.InvalidArgument, 400);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Pre-compute visible branch history before the delegate call so the
|
|
322
|
+
// transport has no back-reference to the View (one-way dependency).
|
|
323
|
+
const history = this.flattenNodes();
|
|
324
|
+
const result = await this._sendDelegate(input, options, history);
|
|
325
|
+
|
|
326
|
+
// Spec: AIT-CT13e
|
|
327
|
+
// Auto-select the new fork in this view when creating a fork.
|
|
328
|
+
if (options?.forkOf) {
|
|
329
|
+
const groupRoot = this._tree.getGroupRoot(options.forkOf);
|
|
330
|
+
|
|
331
|
+
if (result.optimisticMsgIds.length > 0) {
|
|
332
|
+
// The delegate optimistically inserted user messages (edit path).
|
|
333
|
+
// Auto-select the last optimistic msgId — this is deterministic and
|
|
334
|
+
// avoids the sibling-count race that exists when inferring from tree state.
|
|
335
|
+
const lastMsgId = result.optimisticMsgIds.at(-1);
|
|
336
|
+
if (lastMsgId) {
|
|
337
|
+
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: lastMsgId });
|
|
338
|
+
this._cachedNodes = this._computeFlatNodes();
|
|
339
|
+
this._updateVisibleSnapshot(this._cachedNodes);
|
|
340
|
+
this._emitter.emit('update');
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
// No optimistic insert (e.g. regenerate sends no user messages). Defer
|
|
344
|
+
// auto-selection until the server response creates the new sibling.
|
|
345
|
+
// Store the group root (not the raw forkOf) so _pinBranchSelections
|
|
346
|
+
// can match it regardless of which sibling is currently visible.
|
|
347
|
+
this._branchSelections.set(groupRoot, { kind: 'pending', turnId: result.turnId });
|
|
348
|
+
this._logger.debug('DefaultView.send(); deferring fork auto-selection', {
|
|
349
|
+
forkOf: options.forkOf,
|
|
350
|
+
groupRoot,
|
|
351
|
+
turnId: result.turnId,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Bound pending entry lifetime to the turn — clean up on turn-end.
|
|
355
|
+
const turnUnsub = this._tree.on('turn', (evt) => {
|
|
356
|
+
if (evt.type !== EVENT_TURN_END || evt.turnId !== result.turnId) return;
|
|
357
|
+
const sel = this._branchSelections.get(groupRoot);
|
|
358
|
+
if (sel?.kind === 'pending' && sel.turnId === result.turnId) {
|
|
359
|
+
this._branchSelections.delete(groupRoot);
|
|
360
|
+
}
|
|
361
|
+
turnUnsub();
|
|
362
|
+
const idx = this._unsubs.indexOf(turnUnsub);
|
|
363
|
+
if (idx !== -1) this._unsubs.splice(idx, 1);
|
|
364
|
+
});
|
|
365
|
+
this._unsubs.push(turnUnsub);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Spec: AIT-CT5
|
|
373
|
+
async regenerate(messageId: string, options?: SendOptions): Promise<ActiveTurn<TEvent>> {
|
|
374
|
+
this._logger.trace('DefaultView.regenerate();', { messageId });
|
|
375
|
+
|
|
376
|
+
const node = this._tree.getNode(messageId);
|
|
377
|
+
if (!node) {
|
|
378
|
+
throw new Ably.ErrorInfo(
|
|
379
|
+
`unable to regenerate; message not found in tree: ${messageId}`,
|
|
380
|
+
ErrorCode.InvalidArgument,
|
|
381
|
+
400,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const parentId = node.parentId;
|
|
385
|
+
|
|
386
|
+
return this.send([], {
|
|
387
|
+
...options,
|
|
388
|
+
body: {
|
|
389
|
+
history: this._getHistoryBefore(messageId),
|
|
390
|
+
...options?.body,
|
|
391
|
+
},
|
|
392
|
+
forkOf: messageId,
|
|
393
|
+
parent: parentId,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Spec: AIT-CT6
|
|
398
|
+
async edit(
|
|
399
|
+
messageId: string,
|
|
400
|
+
newMessages: TMessage | TMessage[],
|
|
401
|
+
options?: SendOptions,
|
|
402
|
+
): Promise<ActiveTurn<TEvent>> {
|
|
403
|
+
this._logger.trace('DefaultView.edit();', { messageId });
|
|
404
|
+
|
|
405
|
+
const node = this._tree.getNode(messageId);
|
|
406
|
+
if (!node) {
|
|
407
|
+
throw new Ably.ErrorInfo(
|
|
408
|
+
`unable to edit; message not found in tree: ${messageId}`,
|
|
409
|
+
ErrorCode.InvalidArgument,
|
|
410
|
+
400,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const parentId = node.parentId;
|
|
414
|
+
|
|
415
|
+
return this.send(newMessages, {
|
|
416
|
+
...options,
|
|
417
|
+
body: {
|
|
418
|
+
history: this._getHistoryBefore(messageId),
|
|
419
|
+
...options?.body,
|
|
420
|
+
},
|
|
421
|
+
forkOf: messageId,
|
|
422
|
+
parent: parentId,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async update(msgId: string, events: TEvent[], options?: SendOptions): Promise<ActiveTurn<TEvent>> {
|
|
427
|
+
if (this._closed) {
|
|
428
|
+
throw new Ably.ErrorInfo('unable to update; view is closed', ErrorCode.InvalidArgument, 400);
|
|
429
|
+
}
|
|
430
|
+
this._logger.trace('DefaultView.update();', { msgId, eventCount: events.length });
|
|
431
|
+
const eventNodes: EventsNode<TEvent>[] = [{ kind: 'event', msgId, events }];
|
|
432
|
+
return this._sendDelegate([], options, this.flattenNodes(), eventNodes);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private _getHistoryBefore(messageId: string): MessageNode<TMessage>[] {
|
|
436
|
+
this._logger.trace('DefaultView._getHistoryBefore();', { messageId });
|
|
437
|
+
const all = this.flattenNodes();
|
|
438
|
+
const idx = all.findIndex((n) => n.msgId === messageId);
|
|
439
|
+
if (idx === -1) {
|
|
440
|
+
this._logger.warn('DefaultView._getHistoryBefore(); target not in visible nodes, returning full list', {
|
|
441
|
+
messageId,
|
|
442
|
+
});
|
|
443
|
+
return all;
|
|
444
|
+
}
|
|
445
|
+
return all.slice(0, idx);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// -------------------------------------------------------------------------
|
|
449
|
+
// Observation
|
|
450
|
+
// -------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
// Spec: AIT-CT17
|
|
453
|
+
getActiveTurnIds(): Map<string, Set<string>> {
|
|
454
|
+
this._logger.trace('DefaultView.getActiveTurnIds();');
|
|
455
|
+
const allTurns = this._tree.getActiveTurnIds();
|
|
456
|
+
if (this._withheldMsgIds.size === 0) return allTurns;
|
|
457
|
+
|
|
458
|
+
// Filter to turns that have at least one visible message
|
|
459
|
+
const result = new Map<string, Set<string>>();
|
|
460
|
+
for (const [clientId, turnIds] of allTurns) {
|
|
461
|
+
const filtered = new Set<string>();
|
|
462
|
+
for (const turnId of turnIds) {
|
|
463
|
+
if (this._lastVisibleTurnIds.has(turnId)) filtered.add(turnId);
|
|
464
|
+
}
|
|
465
|
+
if (filtered.size > 0) result.set(clientId, filtered);
|
|
466
|
+
}
|
|
467
|
+
return result;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// -------------------------------------------------------------------------
|
|
471
|
+
// Event subscription
|
|
472
|
+
// -------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
// Spec: AIT-CT8a, AIT-CT8b, AIT-CT8e
|
|
475
|
+
on(event: 'update', handler: () => void): () => void;
|
|
476
|
+
on(event: 'ably-message', handler: (msg: Ably.InboundMessage) => void): () => void;
|
|
477
|
+
on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
|
|
478
|
+
on(
|
|
479
|
+
event: 'update' | 'ably-message' | 'turn',
|
|
480
|
+
handler: (() => void) | ((msg: Ably.InboundMessage) => void) | ((event: TurnLifecycleEvent) => void),
|
|
481
|
+
): () => void {
|
|
482
|
+
// CAST: overload signatures enforce correct handler types per event name.
|
|
483
|
+
const cb = handler as (arg: ViewEventsMap[keyof ViewEventsMap]) => void;
|
|
484
|
+
this._emitter.on(event, cb);
|
|
485
|
+
return () => {
|
|
486
|
+
this._emitter.off(event, cb);
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// -------------------------------------------------------------------------
|
|
491
|
+
// Lifecycle
|
|
492
|
+
// -------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Tear down the view — unsubscribe from tree events.
|
|
496
|
+
*/
|
|
497
|
+
close(): void {
|
|
498
|
+
this._logger.info('DefaultView.close();');
|
|
499
|
+
this._closed = true;
|
|
500
|
+
this._loadingOlder = false;
|
|
501
|
+
for (const unsub of this._unsubs) unsub();
|
|
502
|
+
this._unsubs.length = 0;
|
|
503
|
+
this._emitter.off();
|
|
504
|
+
this._branchSelections.clear();
|
|
505
|
+
this._withheldMsgIds.clear();
|
|
506
|
+
this._withheldBuffer.length = 0;
|
|
507
|
+
this._onClose?.();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// -------------------------------------------------------------------------
|
|
511
|
+
// Private: history loading
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
private async _loadFirstPage(limit: number): Promise<void> {
|
|
515
|
+
// Snapshot before loading — everything already in the tree stays visible
|
|
516
|
+
const beforeMsgIds = new Set(this._tree.flattenNodes(this._resolveSelections()).map((n) => n.msgId));
|
|
517
|
+
|
|
518
|
+
const firstPage = await decodeHistory(this._channel, this._codec, { limit }, this._logger);
|
|
519
|
+
if (this._closed) return;
|
|
520
|
+
const { newVisible, lastPage } = await this._loadUntilVisible(firstPage, limit, beforeMsgIds);
|
|
521
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- close() may be called during await
|
|
522
|
+
if (this._closed) return;
|
|
523
|
+
|
|
524
|
+
this._lastHistoryPage = lastPage;
|
|
525
|
+
this._hasMoreHistory = lastPage.hasNext();
|
|
526
|
+
|
|
527
|
+
// Split into withheld (older, kept hidden) and released (newest, shown now).
|
|
528
|
+
// Only add the actually-withheld messages to the set — adding all then
|
|
529
|
+
// releasing would cause a spurious empty-list update if a tree event fires
|
|
530
|
+
// between the two operations.
|
|
531
|
+
const released = newVisible.slice(-limit);
|
|
532
|
+
const withheld = newVisible.slice(0, -limit);
|
|
533
|
+
for (const n of withheld) {
|
|
534
|
+
this._withheldMsgIds.add(n.msgId);
|
|
535
|
+
}
|
|
536
|
+
this._withheldBuffer.push(...withheld);
|
|
537
|
+
this._releaseWithheld(released);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private async _loadAndReveal(page: HistoryPage<TMessage>, limit: number): Promise<void> {
|
|
541
|
+
// Everything currently in the tree is "already known"
|
|
542
|
+
const alreadyKnown = new Set(this._tree.flattenNodes(this._resolveSelections()).map((n) => n.msgId));
|
|
543
|
+
|
|
544
|
+
const { newVisible, lastPage } = await this._loadUntilVisible(page, limit, alreadyKnown);
|
|
545
|
+
if (this._closed) return;
|
|
546
|
+
this._lastHistoryPage = lastPage;
|
|
547
|
+
this._hasMoreHistory = lastPage.hasNext();
|
|
548
|
+
|
|
549
|
+
// Release the newest `limit` items; rest stays in buffer.
|
|
550
|
+
// Only add actually-withheld messages to the set — adding all then
|
|
551
|
+
// releasing would cause a spurious empty-list update if a tree event
|
|
552
|
+
// fires between the two operations.
|
|
553
|
+
const batch = newVisible.slice(-limit);
|
|
554
|
+
const withheld = newVisible.slice(0, -limit);
|
|
555
|
+
for (const n of withheld) {
|
|
556
|
+
this._withheldMsgIds.add(n.msgId);
|
|
557
|
+
}
|
|
558
|
+
this._withheldBuffer.push(...withheld);
|
|
559
|
+
this._releaseWithheld(batch);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private _processHistoryPage(page: HistoryPage<TMessage>): void {
|
|
563
|
+
this._processingHistory = true;
|
|
564
|
+
try {
|
|
565
|
+
for (const item of page.items) {
|
|
566
|
+
const msgId = item.headers[HEADER_MSG_ID];
|
|
567
|
+
if (!msgId) continue;
|
|
568
|
+
this._tree.upsert(msgId, item.message, item.headers, item.serial);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
for (const msg of page.rawMessages) {
|
|
572
|
+
this._tree.emitAblyMessage(msg);
|
|
573
|
+
}
|
|
574
|
+
} finally {
|
|
575
|
+
this._processingHistory = false;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
private async _loadUntilVisible(
|
|
580
|
+
firstPage: HistoryPage<TMessage>,
|
|
581
|
+
target: number,
|
|
582
|
+
beforeMsgIds: Set<string>,
|
|
583
|
+
): Promise<{ newVisible: MessageNode<TMessage>[]; lastPage: HistoryPage<TMessage> }> {
|
|
584
|
+
this._processHistoryPage(firstPage);
|
|
585
|
+
let page = firstPage;
|
|
586
|
+
|
|
587
|
+
const newVisibleCount = (): number => {
|
|
588
|
+
let count = 0;
|
|
589
|
+
for (const n of this._tree.flattenNodes(this._resolveSelections())) {
|
|
590
|
+
if (!beforeMsgIds.has(n.msgId)) count++;
|
|
591
|
+
}
|
|
592
|
+
return count;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
while (newVisibleCount() < target && page.hasNext()) {
|
|
596
|
+
const nextPage = await page.next();
|
|
597
|
+
if (!nextPage || this._closed) break;
|
|
598
|
+
this._processHistoryPage(nextPage);
|
|
599
|
+
page = nextPage;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const newVisible = this._tree.flattenNodes(this._resolveSelections()).filter((n) => !beforeMsgIds.has(n.msgId));
|
|
603
|
+
return { newVisible, lastPage: page };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Spec: AIT-CT11a
|
|
607
|
+
private _releaseWithheld(nodes: MessageNode<TMessage>[]): void {
|
|
608
|
+
for (const n of nodes) {
|
|
609
|
+
this._withheldMsgIds.delete(n.msgId);
|
|
610
|
+
}
|
|
611
|
+
if (nodes.length > 0) {
|
|
612
|
+
this._cachedNodes = this._computeFlatNodes();
|
|
613
|
+
this._updateVisibleSnapshot(this._cachedNodes);
|
|
614
|
+
this._emitter.emit('update');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// -------------------------------------------------------------------------
|
|
619
|
+
// Private: scoped event forwarding
|
|
620
|
+
// -------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
private _updateVisibleSnapshot(nodes?: MessageNode<TMessage>[]): void {
|
|
623
|
+
const resolved = nodes ?? this.flattenNodes();
|
|
624
|
+
this._lastVisibleIds = resolved.map((n) => n.msgId);
|
|
625
|
+
this._lastVisibleMessages = resolved.map((n) => n.message);
|
|
626
|
+
this._lastVisibleTurnIds = new Set<string>();
|
|
627
|
+
for (const n of resolved) {
|
|
628
|
+
const turnId = n.headers[HEADER_TURN_ID];
|
|
629
|
+
if (turnId) this._lastVisibleTurnIds.add(turnId);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private _onTreeUpdate(): void {
|
|
634
|
+
// Suppress update forwarding while processing history pages. During
|
|
635
|
+
// _processHistoryPage, each tree.upsert() fires this handler synchronously
|
|
636
|
+
// — but _withheldMsgIds hasn't been populated yet, so flattenNodes() would
|
|
637
|
+
// return unfiltered history. Without this guard, subscribers briefly see all
|
|
638
|
+
// history messages before the pagination window is applied. The final update
|
|
639
|
+
// is emitted by _releaseWithheld after withholding is set up.
|
|
640
|
+
// Scoped to _processingHistory (not _loadingOlder) so that live streaming
|
|
641
|
+
// updates arriving during the async history fetch are still forwarded.
|
|
642
|
+
if (this._processingHistory) return;
|
|
643
|
+
|
|
644
|
+
const currentVersion = this._tree.structuralVersion;
|
|
645
|
+
|
|
646
|
+
// Content-only fast path: the tree structure hasn't changed (no new
|
|
647
|
+
// nodes, deletions, or serial reorders), so the cached node list is
|
|
648
|
+
// still structurally valid. The tree mutated an existing node's
|
|
649
|
+
// .message in place - check if any visible message reference changed.
|
|
650
|
+
// JS single-threaded: structuralVersion cannot change between the
|
|
651
|
+
// check and the response within this synchronous handler invocation.
|
|
652
|
+
if (currentVersion === this._lastStructuralVersion) {
|
|
653
|
+
const changed = this._cachedNodes.some((node, i) => node.message !== this._lastVisibleMessages[i]);
|
|
654
|
+
if (changed) {
|
|
655
|
+
this._lastVisibleMessages = this._cachedNodes.map((n) => n.message);
|
|
656
|
+
this._cachedNodes = [...this._cachedNodes];
|
|
657
|
+
this._emitter.emit('update');
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Structural update: full re-walk required.
|
|
663
|
+
this._lastStructuralVersion = currentVersion;
|
|
664
|
+
|
|
665
|
+
// Pin selections for previously-visible nodes that now have siblings.
|
|
666
|
+
// This prevents new forks (from other views' edits/regenerates) from
|
|
667
|
+
// shifting this view to a branch the user didn't navigate to.
|
|
668
|
+
this._pinBranchSelections();
|
|
669
|
+
this._resolvePendingSelections();
|
|
670
|
+
|
|
671
|
+
const nodes = this._computeFlatNodes();
|
|
672
|
+
const newIds = nodes.map((n) => n.msgId);
|
|
673
|
+
const newMessages = nodes.map((n) => n.message);
|
|
674
|
+
if (this._visibleChanged(newIds, newMessages)) {
|
|
675
|
+
this._cachedNodes = nodes;
|
|
676
|
+
this._updateVisibleSnapshot(nodes);
|
|
677
|
+
this._emitter.emit('update');
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Build a resolved selections map from `_branchSelections` for passing
|
|
683
|
+
* to `tree.flattenNodes()`. Pending entries (no sibling yet) are omitted,
|
|
684
|
+
* causing the tree to use the default (latest sibling).
|
|
685
|
+
* @returns Resolved map of groupRoot → selectedMsgId.
|
|
686
|
+
*/
|
|
687
|
+
private _resolveSelections(): Map<string, string> {
|
|
688
|
+
const resolved = new Map<string, string>();
|
|
689
|
+
for (const [groupRoot, sel] of this._branchSelections) {
|
|
690
|
+
if (sel.kind === 'pending') continue;
|
|
691
|
+
resolved.set(groupRoot, sel.selectedId);
|
|
692
|
+
}
|
|
693
|
+
return resolved;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* For each previously-visible message that now has siblings but no
|
|
698
|
+
* explicit selection, pin the selection to that message's msgId.
|
|
699
|
+
* This preserves the current branch when new forks appear from
|
|
700
|
+
* other views or external sources.
|
|
701
|
+
*
|
|
702
|
+
* Exception: if the fork was initiated by this view (tracked as a
|
|
703
|
+
* `pending` BranchSelection), select the newest sibling instead of
|
|
704
|
+
* pinning the old one. This handles regenerate, where no optimistic
|
|
705
|
+
* insert was possible at send time.
|
|
706
|
+
*/
|
|
707
|
+
private _pinBranchSelections(): void {
|
|
708
|
+
for (const msgId of this._lastVisibleIds) {
|
|
709
|
+
if (!this._tree.hasSiblings(msgId)) continue;
|
|
710
|
+
const groupRoot = this._tree.getGroupRoot(msgId);
|
|
711
|
+
const existing = this._branchSelections.get(groupRoot);
|
|
712
|
+
|
|
713
|
+
// Spec: AIT-CT13e
|
|
714
|
+
// Check if this fork was initiated by this view (e.g. regenerate).
|
|
715
|
+
// If so, select the newest sibling — but only if it belongs to the
|
|
716
|
+
// pending turn. Without this check, a sibling from another view's
|
|
717
|
+
// concurrent fork would be incorrectly auto-selected.
|
|
718
|
+
if (existing?.kind === 'pending') {
|
|
719
|
+
const nodes = this._tree.getSiblingNodes(msgId);
|
|
720
|
+
const newest = nodes.at(-1);
|
|
721
|
+
if (newest && newest.msgId !== msgId) {
|
|
722
|
+
const newestTurnId = newest.headers[HEADER_TURN_ID];
|
|
723
|
+
if (newestTurnId === existing.turnId) {
|
|
724
|
+
this._logger.debug('DefaultView._pinBranchSelections(); auto-selecting pending fork', {
|
|
725
|
+
msgId,
|
|
726
|
+
newestId: newest.msgId,
|
|
727
|
+
turnId: existing.turnId,
|
|
728
|
+
});
|
|
729
|
+
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Spec: AIT-CT13f
|
|
736
|
+
// External fork — pin to the currently-visible sibling.
|
|
737
|
+
if (existing) continue; // already have a selection
|
|
738
|
+
this._branchSelections.set(groupRoot, { kind: 'pinned', selectedId: msgId });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Resolve pending selections that are no longer on the visible branch.
|
|
744
|
+
* `_pinBranchSelections` only checks visible nodes, so if the user navigated
|
|
745
|
+
* away before the server response arrived, the pending entry would linger.
|
|
746
|
+
* This pass checks all pending entries against the tree directly.
|
|
747
|
+
*/
|
|
748
|
+
private _resolvePendingSelections(): void {
|
|
749
|
+
for (const [groupRoot, sel] of this._branchSelections) {
|
|
750
|
+
if (sel.kind !== 'pending') continue;
|
|
751
|
+
const nodes = this._tree.getSiblingNodes(groupRoot);
|
|
752
|
+
if (nodes.length <= 1) continue;
|
|
753
|
+
const newest = nodes.at(-1);
|
|
754
|
+
if (!newest || newest.msgId === groupRoot) continue;
|
|
755
|
+
const newestTurnId = newest.headers[HEADER_TURN_ID];
|
|
756
|
+
if (newestTurnId === sel.turnId) {
|
|
757
|
+
this._logger.debug('DefaultView._resolvePendingSelections(); resolving off-branch pending', {
|
|
758
|
+
groupRoot,
|
|
759
|
+
newestId: newest.msgId,
|
|
760
|
+
turnId: sel.turnId,
|
|
761
|
+
});
|
|
762
|
+
this._branchSelections.set(groupRoot, { kind: 'auto', selectedId: newest.msgId });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private _onTreeAblyMessage(msg: Ably.InboundMessage): void {
|
|
768
|
+
// Re-emit only if the message corresponds to a visible node
|
|
769
|
+
const headers = getHeaders(msg);
|
|
770
|
+
const msgId = headers[HEADER_MSG_ID];
|
|
771
|
+
if (!msgId) {
|
|
772
|
+
// Non-message events (turn-start, turn-end, cancel) — always forward
|
|
773
|
+
this._emitter.emit('ably-message', msg);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
// Check that msgId is on the visible branch and not withheld
|
|
777
|
+
if (this._lastVisibleIds.includes(msgId)) {
|
|
778
|
+
this._emitter.emit('ably-message', msg);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private _onTreeTurn(event: TurnLifecycleEvent): void {
|
|
783
|
+
// Check if any messages for this turn are already on the visible branch.
|
|
784
|
+
if (this._lastVisibleTurnIds.has(event.turnId)) {
|
|
785
|
+
this._emitter.emit('turn', event);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// For turn-start, use branch metadata to predict visibility before
|
|
790
|
+
// messages arrive. Own turns have optimistic inserts (caught above).
|
|
791
|
+
// Remote turns carry parent/forkOf from the server.
|
|
792
|
+
if (event.type === EVENT_TURN_START && this._isTurnStartVisible(event)) {
|
|
793
|
+
// Track the predicted turnId so the corresponding turn-end is not
|
|
794
|
+
// dropped if it arrives before messages update the snapshot.
|
|
795
|
+
this._lastVisibleTurnIds.add(event.turnId);
|
|
796
|
+
this._emitter.emit('turn', event);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Predict whether a turn-start's messages will be visible on this view's branch
|
|
802
|
+
* using the parent/forkOf metadata from the event.
|
|
803
|
+
* @param event - The turn-start lifecycle event with optional branch metadata.
|
|
804
|
+
* @returns True if the turn's messages are expected to be visible on this view's branch.
|
|
805
|
+
*/
|
|
806
|
+
private _isTurnStartVisible(event: TurnLifecycleEvent & { type: typeof EVENT_TURN_START }): boolean {
|
|
807
|
+
const { parent } = event;
|
|
808
|
+
|
|
809
|
+
// No parent metadata — can't determine branch, forward as default.
|
|
810
|
+
// This covers root turns (parent omitted) and backward compat.
|
|
811
|
+
if (parent === undefined) return true;
|
|
812
|
+
|
|
813
|
+
// Check if the parent is on the visible branch
|
|
814
|
+
return this._lastVisibleIds.includes(parent);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private _visibleChanged(newIds: string[], newMessages: TMessage[]): boolean {
|
|
818
|
+
if (newIds.length !== this._lastVisibleIds.length) return true;
|
|
819
|
+
for (const [i, newId] of newIds.entries()) {
|
|
820
|
+
if (newId !== this._lastVisibleIds[i]) return true;
|
|
821
|
+
}
|
|
822
|
+
// Also detect in-place content updates (e.g. streaming) via reference comparison
|
|
823
|
+
for (const [i, msg] of newMessages.entries()) {
|
|
824
|
+
if (msg !== this._lastVisibleMessages[i]) return true;
|
|
825
|
+
}
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
// Factory
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Create a View that projects a paginated window over a Tree.
|
|
836
|
+
* @param options - The tree, channel, codec, and logger to use.
|
|
837
|
+
* @returns A new {@link DefaultView} instance.
|
|
838
|
+
*/
|
|
839
|
+
export const createView = <TEvent, TMessage>(options: ViewOptions<TEvent, TMessage>): DefaultView<TEvent, TMessage> =>
|
|
840
|
+
new DefaultView(options);
|