@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,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* decodeHistory — load conversation history from an Ably channel and
|
|
3
|
-
* return decoded messages as a
|
|
3
|
+
* return decoded messages as a paginated HistoryPage result.
|
|
4
4
|
*
|
|
5
5
|
* Uses a fresh decoder (not shared with the live subscription) to avoid
|
|
6
6
|
* state conflicts. Per-turn accumulators handle interleaved turns correctly.
|
|
@@ -16,17 +16,26 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Because Ably history returns newest-first while the decoder requires
|
|
18
18
|
* chronological order, all collected Ably messages are re-decoded from
|
|
19
|
-
* oldest to newest
|
|
20
|
-
* page boundaries correctly.
|
|
19
|
+
* oldest to newest at the point a result is built. This handles turns
|
|
20
|
+
* that span page boundaries correctly. The fetch loop uses a cheap
|
|
21
|
+
* header-based completion counter to decide when to stop paging, so the
|
|
22
|
+
* full decode runs exactly once per traversal regardless of page count.
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import type * as Ably from 'ably';
|
|
24
26
|
|
|
25
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
HEADER_AMEND,
|
|
29
|
+
HEADER_DISCRETE,
|
|
30
|
+
HEADER_MSG_ID,
|
|
31
|
+
HEADER_STATUS,
|
|
32
|
+
HEADER_STREAM,
|
|
33
|
+
HEADER_TURN_ID,
|
|
34
|
+
} from '../../constants.js';
|
|
26
35
|
import type { Logger } from '../../logger.js';
|
|
27
36
|
import { getHeaders } from '../../utils.js';
|
|
28
37
|
import type { Codec, DecoderOutput, MessageAccumulator } from '../codec/types.js';
|
|
29
|
-
import type {
|
|
38
|
+
import type { HistoryPage, LoadHistoryOptions } from './types.js';
|
|
30
39
|
|
|
31
40
|
// ---------------------------------------------------------------------------
|
|
32
41
|
// Shared state across pages within one history traversal
|
|
@@ -42,8 +51,36 @@ interface HistoryState<TEvent, TMessage> {
|
|
|
42
51
|
returnedRawCount: number;
|
|
43
52
|
/** The last Ably page cursor for continued pagination. */
|
|
44
53
|
lastAblyPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined;
|
|
45
|
-
/**
|
|
46
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Cached result of the last {@link decodeAll} call, reused while
|
|
56
|
+
* `rawMessages` is unchanged. Invalidated implicitly by comparing
|
|
57
|
+
* {@link cachedAtRawLength} against `rawMessages.length`; `rawMessages`
|
|
58
|
+
* is append-only within a traversal so length is a sufficient key.
|
|
59
|
+
*/
|
|
60
|
+
cachedDecode: DecodedItem<TMessage>[] | undefined;
|
|
61
|
+
/** `rawMessages.length` at the time {@link cachedDecode} was produced. */
|
|
62
|
+
cachedAtRawLength: number;
|
|
63
|
+
/**
|
|
64
|
+
* `x-ably-msg-id`s for which the decoder has something to produce output
|
|
65
|
+
* from: any `message.create` / `message.update` / `message.append` with
|
|
66
|
+
* `x-ably-stream: "true"` (establishes a tracker via create or
|
|
67
|
+
* first-contact), or a `message.create` carrying `x-ably-discrete` (a
|
|
68
|
+
* discrete message, created and terminated in one wire message).
|
|
69
|
+
*/
|
|
70
|
+
startedMsgIds: Set<string>;
|
|
71
|
+
/**
|
|
72
|
+
* `x-ably-msg-id`s with a terminal wire signal: either `x-ably-discrete`
|
|
73
|
+
* on a `message.create` (discrete message) or `x-ably-status: "finished"`
|
|
74
|
+
* / `"aborted"` on any action (closed stream).
|
|
75
|
+
*/
|
|
76
|
+
terminatedMsgIds: Set<string>;
|
|
77
|
+
/**
|
|
78
|
+
* `x-ably-msg-id`s that are both started AND terminated - ready to appear
|
|
79
|
+
* in the decoded output. The fetch loop reads this set's size to decide
|
|
80
|
+
* when to stop paging, avoiding a full decode per page. Maintained
|
|
81
|
+
* incrementally by {@link countNewCompletions}. Grows monotonically.
|
|
82
|
+
*/
|
|
83
|
+
completedMsgIds: Set<string>;
|
|
47
84
|
logger: Logger;
|
|
48
85
|
}
|
|
49
86
|
|
|
@@ -84,10 +121,16 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
|
|
|
84
121
|
const defaultAccumulator = state.codec.createAccumulator();
|
|
85
122
|
let orderCounter = 0;
|
|
86
123
|
|
|
87
|
-
// Headers for discrete messages
|
|
124
|
+
// Headers and serials for non-turn discrete messages, keyed by x-ably-msg-id.
|
|
88
125
|
const discreteHeaders = new Map<string, Record<string, string>>();
|
|
89
|
-
// Serials for discrete messages, keyed by codec message key.
|
|
90
126
|
const discreteSerials = new Map<string, string>();
|
|
127
|
+
// Track which msgId produced each non-turn discrete message output (in order).
|
|
128
|
+
const discreteMsgIds: string[] = [];
|
|
129
|
+
|
|
130
|
+
// Cross-turn event targets to complete after all events are processed.
|
|
131
|
+
// Deferred so that finish/abort events that follow the update in serial
|
|
132
|
+
// order can still process on the active message (e.g. applying messageMetadata).
|
|
133
|
+
const deferredCompletions: { accumulator: MessageAccumulator<TEvent, TMessage>; messageId: string }[] = [];
|
|
91
134
|
|
|
92
135
|
for (const msg of chronological) {
|
|
93
136
|
const outputs: DecoderOutput<TEvent, TMessage>[] = decoder.decode(msg);
|
|
@@ -95,6 +138,26 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
|
|
|
95
138
|
const turnId = headers[HEADER_TURN_ID];
|
|
96
139
|
const msgId = headers[HEADER_MSG_ID];
|
|
97
140
|
const serial = msg.serial;
|
|
141
|
+
const amendTarget = headers[HEADER_AMEND];
|
|
142
|
+
|
|
143
|
+
// Cross-turn events target an existing message from a different turn.
|
|
144
|
+
// Route to the owning turn's accumulator via initMessage lifecycle.
|
|
145
|
+
if (amendTarget) {
|
|
146
|
+
for (const turn of turns.values()) {
|
|
147
|
+
if (turn.msgHeaders.has(amendTarget)) {
|
|
148
|
+
const headerKeys = [...turn.msgHeaders.keys()];
|
|
149
|
+
const msgIndex = headerKeys.indexOf(amendTarget);
|
|
150
|
+
const currentMsg = msgIndex === -1 ? undefined : turn.accumulator.messages[msgIndex];
|
|
151
|
+
if (currentMsg) {
|
|
152
|
+
turn.accumulator.initMessage(amendTarget, currentMsg);
|
|
153
|
+
}
|
|
154
|
+
turn.accumulator.processOutputs(outputs);
|
|
155
|
+
deferredCompletions.push({ accumulator: turn.accumulator, messageId: amendTarget });
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
98
161
|
|
|
99
162
|
if (turnId) {
|
|
100
163
|
let turn = turns.get(turnId);
|
|
@@ -123,81 +186,65 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
|
|
|
123
186
|
turn.accumulator.processOutputs(outputs);
|
|
124
187
|
} else {
|
|
125
188
|
defaultAccumulator.processOutputs(outputs);
|
|
126
|
-
}
|
|
127
189
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
190
|
+
// Capture headers and serial for non-turn discrete messages by x-ably-msg-id.
|
|
191
|
+
for (const output of outputs) {
|
|
192
|
+
if (output.kind === 'message' && msgId) {
|
|
193
|
+
discreteMsgIds.push(msgId);
|
|
194
|
+
const existingDiscrete = discreteHeaders.get(msgId);
|
|
195
|
+
if (!existingDiscrete) {
|
|
196
|
+
discreteHeaders.set(msgId, { ...headers });
|
|
197
|
+
if (serial) discreteSerials.set(msgId, serial);
|
|
198
|
+
} else if (Object.keys(headers).length > 0) {
|
|
199
|
+
Object.assign(existingDiscrete, headers);
|
|
200
|
+
}
|
|
138
201
|
}
|
|
139
202
|
}
|
|
140
203
|
}
|
|
141
204
|
}
|
|
142
205
|
|
|
206
|
+
// Complete any messages that were re-activated for cross-turn updates.
|
|
207
|
+
// Idempotent — if finish already removed the message from active tracking,
|
|
208
|
+
// completeMessage is a no-op.
|
|
209
|
+
for (const { accumulator, messageId } of deferredCompletions) {
|
|
210
|
+
accumulator.completeMessage(messageId);
|
|
211
|
+
}
|
|
212
|
+
|
|
143
213
|
// Collect completed messages in chronological order (oldest first) by turn.
|
|
144
214
|
const completed: DecodedItem<TMessage>[] = [];
|
|
145
215
|
|
|
146
|
-
|
|
147
|
-
|
|
216
|
+
// Default accumulator messages: pair with their discrete headers by position.
|
|
217
|
+
for (const [i, msg] of defaultAccumulator.completedMessages.entries()) {
|
|
218
|
+
const mid = discreteMsgIds[i];
|
|
148
219
|
completed.push({
|
|
149
220
|
message: msg,
|
|
150
|
-
headers: discreteHeaders.get(
|
|
151
|
-
serial: discreteSerials.get(
|
|
221
|
+
headers: mid ? (discreteHeaders.get(mid) ?? {}) : {},
|
|
222
|
+
serial: mid ? (discreteSerials.get(mid) ?? '') : '',
|
|
152
223
|
});
|
|
153
224
|
}
|
|
154
225
|
|
|
155
226
|
const sorted = [...turns.values()].toSorted((a, b) => a.firstSeen - b.firstSeen);
|
|
156
227
|
for (const turn of sorted) {
|
|
157
228
|
// Assign headers and serials to each completed message in this turn.
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
// First pass: resolve discrete messages and mark their msg-ids as claimed
|
|
163
|
-
const turnKeyHeaders = new Map<string, Record<string, string>>();
|
|
164
|
-
const turnKeySerials = new Map<string, string>();
|
|
165
|
-
for (const msg of turn.accumulator.completedMessages) {
|
|
166
|
-
const key = state.getMessageKey(msg);
|
|
167
|
-
const discrete = discreteHeaders.get(key);
|
|
168
|
-
if (discrete) {
|
|
169
|
-
turnKeyHeaders.set(key, discrete);
|
|
170
|
-
const dSerial = discreteSerials.get(key);
|
|
171
|
-
if (dSerial) turnKeySerials.set(key, dSerial);
|
|
172
|
-
const mid = discrete[HEADER_MSG_ID];
|
|
173
|
-
if (mid) claimedMsgIds.add(mid);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Second pass: assign unclaimed msg-id entries to remaining messages
|
|
178
|
-
const unclaimedEntries = [...turn.msgHeaders.entries()].filter(([mid]) => !claimedMsgIds.has(mid));
|
|
179
|
-
let unclaimedIdx = 0;
|
|
229
|
+
// The turn's msgHeaders map is keyed by x-ably-msg-id and ordered by
|
|
230
|
+
// first-seen. Completed messages are matched positionally.
|
|
231
|
+
const headerEntries = [...turn.msgHeaders.entries()];
|
|
232
|
+
let headerIdx = 0;
|
|
180
233
|
|
|
181
234
|
for (const msg of turn.accumulator.completedMessages) {
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
235
|
+
const entry = headerEntries[headerIdx];
|
|
236
|
+
if (entry) {
|
|
237
|
+
const [mid, hdrs] = entry;
|
|
238
|
+
completed.push({
|
|
239
|
+
message: msg,
|
|
240
|
+
headers: hdrs,
|
|
241
|
+
serial: turn.msgSerials.get(mid) ?? '',
|
|
242
|
+
});
|
|
243
|
+
headerIdx++;
|
|
244
|
+
} else {
|
|
245
|
+
completed.push({ message: msg, headers: {}, serial: '' });
|
|
190
246
|
}
|
|
191
247
|
}
|
|
192
|
-
|
|
193
|
-
for (const msg of turn.accumulator.completedMessages) {
|
|
194
|
-
const key = state.getMessageKey(msg);
|
|
195
|
-
completed.push({
|
|
196
|
-
message: msg,
|
|
197
|
-
headers: turnKeyHeaders.get(key) ?? {},
|
|
198
|
-
serial: turnKeySerials.get(key) ?? '',
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
248
|
}
|
|
202
249
|
|
|
203
250
|
// Reverse to newest-first. The consumer slices from the front for the
|
|
@@ -205,12 +252,113 @@ const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): Dec
|
|
|
205
252
|
return completed.toReversed();
|
|
206
253
|
};
|
|
207
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Cached wrapper around {@link decodeAll}. Returns the previous result when
|
|
257
|
+
* `rawMessages` hasn't changed since the last decode; otherwise re-decodes
|
|
258
|
+
* and updates the cache. The cache key is `rawMessages.length` because
|
|
259
|
+
* `rawMessages` is append-only within a traversal.
|
|
260
|
+
* @param state - The shared history traversal state.
|
|
261
|
+
* @returns Completed messages in newest-first order.
|
|
262
|
+
*/
|
|
263
|
+
const decodeAllCached = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): DecodedItem<TMessage>[] => {
|
|
264
|
+
if (state.cachedDecode && state.cachedAtRawLength === state.rawMessages.length) {
|
|
265
|
+
return state.cachedDecode;
|
|
266
|
+
}
|
|
267
|
+
const result = decodeAll(state);
|
|
268
|
+
state.cachedDecode = result;
|
|
269
|
+
state.cachedAtRawLength = state.rawMessages.length;
|
|
270
|
+
return result;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Incremental completion counting (avoids full decode inside the fetch loop)
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Scan newly-added raw messages and track which `x-ably-msg-id`s have
|
|
279
|
+
* become complete. Used by {@link fetchUntilLimit} to decide when enough
|
|
280
|
+
* completed messages have been collected, without running the decoder.
|
|
281
|
+
*
|
|
282
|
+
* A msg-id is considered complete only when BOTH of these have been seen:
|
|
283
|
+
* - a "start" signal: either `x-ably-discrete` on a `message.create`
|
|
284
|
+
* (discrete messages are created and terminated by the same wire
|
|
285
|
+
* message), OR any `message.create` / `message.update` / `message.append`
|
|
286
|
+
* with `x-ably-stream: "true"` (the decoder establishes a tracker via
|
|
287
|
+
* create or first-contact).
|
|
288
|
+
* - a "terminal" signal: `x-ably-discrete` on the create, or
|
|
289
|
+
* `x-ably-status: "finished"` / `"aborted"` on any later action.
|
|
290
|
+
*
|
|
291
|
+
* Why update and append count as starts: Ably history can compact a live
|
|
292
|
+
* `create + append + ... + append{status:finished}` sequence into a single
|
|
293
|
+
* `message.update` with `STREAM=true` and `STATUS=finished`. The decoder
|
|
294
|
+
* handles that in {@link _decodeUpdate} via first-contact. Counting only
|
|
295
|
+
* `message.create` as a start would cause the fetch loop to page past a
|
|
296
|
+
* compacted turn without ever marking it complete.
|
|
297
|
+
*
|
|
298
|
+
* Requiring both halves matters when a streaming turn spans a page
|
|
299
|
+
* boundary: the terminal arrives in the newer page (fetched first) while
|
|
300
|
+
* the start sits in an older page. Counting the terminal alone would stop
|
|
301
|
+
* the fetch loop prematurely - the decoder would have no stream state to
|
|
302
|
+
* resolve, and the message wouldn't make it into the result.
|
|
303
|
+
*
|
|
304
|
+
* Messages skipped for counting:
|
|
305
|
+
* - Missing `x-ably-msg-id`: lifecycle events not tied to a domain message.
|
|
306
|
+
* - `x-ably-amend` set: amendments target an existing message, not a new
|
|
307
|
+
* completion.
|
|
308
|
+
* - `message.delete`: clears the tracker, doesn't produce output.
|
|
309
|
+
*
|
|
310
|
+
* Known edge case: if Ably history is truncated and a terminal survives
|
|
311
|
+
* while every start signal for its msg-id has rolled off, the counter will
|
|
312
|
+
* never mark that `msg-id` complete. The loop keeps fetching until it runs
|
|
313
|
+
* out of pages, then returns whatever the decoder actually produced.
|
|
314
|
+
* Matches the existing behaviour for the same truncation scenario.
|
|
315
|
+
* @param state - The shared history traversal state.
|
|
316
|
+
* @param newMessages - The Ably messages just pushed onto `state.rawMessages`.
|
|
317
|
+
*/
|
|
318
|
+
const countNewCompletions = <TEvent, TMessage>(
|
|
319
|
+
state: HistoryState<TEvent, TMessage>,
|
|
320
|
+
newMessages: readonly Ably.InboundMessage[],
|
|
321
|
+
): void => {
|
|
322
|
+
for (const msg of newMessages) {
|
|
323
|
+
const headers = getHeaders(msg);
|
|
324
|
+
const msgId = headers[HEADER_MSG_ID];
|
|
325
|
+
if (!msgId) continue;
|
|
326
|
+
// Amendments target an existing message, not a new completion.
|
|
327
|
+
// Defensive: no current encoder path produces an amendment carrying
|
|
328
|
+
// HEADER_STREAM=true, HEADER_STATUS, or HEADER_DISCRETE.
|
|
329
|
+
if (headers[HEADER_AMEND]) continue;
|
|
330
|
+
|
|
331
|
+
const action = msg.action;
|
|
332
|
+
const isDiscreteCreate = action === 'message.create' && HEADER_DISCRETE in headers;
|
|
333
|
+
// Any content-producing action on a streamed serial counts as a start:
|
|
334
|
+
// the decoder uses create or first-contact (update/append) to establish
|
|
335
|
+
// its tracker. Delete clears tracker state and emits nothing, so it
|
|
336
|
+
// never counts as a start.
|
|
337
|
+
const hasStreamContent =
|
|
338
|
+
headers[HEADER_STREAM] === 'true' &&
|
|
339
|
+
(action === 'message.create' || action === 'message.update' || action === 'message.append');
|
|
340
|
+
const status = headers[HEADER_STATUS];
|
|
341
|
+
const isTerminal = status === 'finished' || status === 'aborted';
|
|
342
|
+
|
|
343
|
+
if (isDiscreteCreate || hasStreamContent) state.startedMsgIds.add(msgId);
|
|
344
|
+
if (isDiscreteCreate || isTerminal) state.terminatedMsgIds.add(msgId);
|
|
345
|
+
if (state.startedMsgIds.has(msgId) && state.terminatedMsgIds.has(msgId)) {
|
|
346
|
+
state.completedMsgIds.add(msgId);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
208
351
|
// ---------------------------------------------------------------------------
|
|
209
352
|
// Fetch Ably pages until we have enough completed messages
|
|
210
353
|
// ---------------------------------------------------------------------------
|
|
211
354
|
|
|
212
355
|
/**
|
|
213
356
|
* Fetch Ably history pages until we have enough completed messages.
|
|
357
|
+
*
|
|
358
|
+
* The loop uses {@link countNewCompletions} to decide when to stop -
|
|
359
|
+
* a cheap O(new messages) header scan - rather than running the full
|
|
360
|
+
* decoder per page. The decoder runs exactly once later, in
|
|
361
|
+
* {@link buildResult}, against the fully-collected `rawMessages`.
|
|
214
362
|
* @param state - The shared history traversal state.
|
|
215
363
|
* @param ablyPage - The current Ably paginated result to start from.
|
|
216
364
|
* @param limit - Target number of completed messages beyond what has already been returned.
|
|
@@ -222,39 +370,37 @@ const fetchUntilLimit = async <TEvent, TMessage>(
|
|
|
222
370
|
): Promise<void> => {
|
|
223
371
|
state.rawMessages.push(...ablyPage.items);
|
|
224
372
|
state.lastAblyPage = ablyPage;
|
|
373
|
+
countNewCompletions(state, ablyPage.items);
|
|
225
374
|
|
|
226
|
-
|
|
227
|
-
while (
|
|
375
|
+
const target = state.returnedCount + limit;
|
|
376
|
+
while (state.completedMsgIds.size < target && ablyPage.hasNext()) {
|
|
228
377
|
state.logger.debug('decodeHistory.fetchUntilLimit(); fetching next page', {
|
|
229
378
|
collected: state.rawMessages.length,
|
|
230
|
-
|
|
379
|
+
completed: state.completedMsgIds.size,
|
|
231
380
|
});
|
|
232
381
|
const nextPage = await ablyPage.next();
|
|
233
382
|
if (!nextPage) break;
|
|
234
383
|
ablyPage = nextPage;
|
|
235
384
|
state.rawMessages.push(...nextPage.items);
|
|
236
385
|
state.lastAblyPage = nextPage;
|
|
237
|
-
|
|
386
|
+
countNewCompletions(state, nextPage.items);
|
|
238
387
|
}
|
|
239
388
|
};
|
|
240
389
|
|
|
241
390
|
// ---------------------------------------------------------------------------
|
|
242
|
-
// Build
|
|
391
|
+
// Build HistoryPage result from current state
|
|
243
392
|
// ---------------------------------------------------------------------------
|
|
244
393
|
|
|
245
394
|
/**
|
|
246
|
-
* Build a
|
|
395
|
+
* Build a HistoryPage from the current decode state.
|
|
247
396
|
* @param state - The shared history traversal state.
|
|
248
397
|
* @param limit - Max messages per page.
|
|
249
|
-
* @returns A page of decoded
|
|
398
|
+
* @returns A page of decoded history with a `next()` cursor.
|
|
250
399
|
*/
|
|
251
|
-
const buildResult = <TEvent, TMessage>(
|
|
252
|
-
state: HistoryState<TEvent, TMessage>,
|
|
253
|
-
limit: number,
|
|
254
|
-
): PaginatedMessages<TMessage> => {
|
|
400
|
+
const buildResult = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>, limit: number): HistoryPage<TMessage> => {
|
|
255
401
|
// allCompleted is newest-first. Slice from returnedCount for this page,
|
|
256
402
|
// then reverse to chronological for display.
|
|
257
|
-
const allCompleted =
|
|
403
|
+
const allCompleted = decodeAllCached(state);
|
|
258
404
|
|
|
259
405
|
const pageSlice = allCompleted.slice(state.returnedCount, state.returnedCount + limit);
|
|
260
406
|
const chronSlice = [...pageSlice].toReversed();
|
|
@@ -269,9 +415,7 @@ const buildResult = <TEvent, TMessage>(
|
|
|
269
415
|
state.returnedRawCount = state.rawMessages.length;
|
|
270
416
|
|
|
271
417
|
return {
|
|
272
|
-
items: chronSlice.map((d) => d.message),
|
|
273
|
-
itemHeaders: chronSlice.map((d) => d.headers),
|
|
274
|
-
itemSerials: chronSlice.map((d) => d.serial),
|
|
418
|
+
items: chronSlice.map((d) => ({ message: d.message, headers: d.headers, serial: d.serial })),
|
|
275
419
|
rawMessages: rawSlice,
|
|
276
420
|
hasNext: () => moreCompleted || moreAblyPages,
|
|
277
421
|
next: async () => {
|
|
@@ -304,7 +448,7 @@ const buildResult = <TEvent, TMessage>(
|
|
|
304
448
|
* @param codec - The codec for decoding wire messages into domain messages.
|
|
305
449
|
* @param options - Pagination options.
|
|
306
450
|
* @param logger - Logger for diagnostic output.
|
|
307
|
-
* @returns The first page of decoded history
|
|
451
|
+
* @returns The first page of decoded history.
|
|
308
452
|
*/
|
|
309
453
|
// Spec: AIT-CT11, AIT-CT11b
|
|
310
454
|
export const decodeHistory = async <TEvent, TMessage>(
|
|
@@ -312,7 +456,7 @@ export const decodeHistory = async <TEvent, TMessage>(
|
|
|
312
456
|
codec: Codec<TEvent, TMessage>,
|
|
313
457
|
options: LoadHistoryOptions | undefined,
|
|
314
458
|
logger: Logger,
|
|
315
|
-
): Promise<
|
|
459
|
+
): Promise<HistoryPage<TMessage>> => {
|
|
316
460
|
const limit = options?.limit ?? 100;
|
|
317
461
|
const state: HistoryState<TEvent, TMessage> = {
|
|
318
462
|
codec,
|
|
@@ -320,7 +464,11 @@ export const decodeHistory = async <TEvent, TMessage>(
|
|
|
320
464
|
returnedCount: 0,
|
|
321
465
|
returnedRawCount: 0,
|
|
322
466
|
lastAblyPage: undefined,
|
|
323
|
-
|
|
467
|
+
cachedDecode: undefined,
|
|
468
|
+
cachedAtRawLength: 0,
|
|
469
|
+
startedMsgIds: new Set<string>(),
|
|
470
|
+
terminatedMsgIds: new Set<string>(),
|
|
471
|
+
completedMsgIds: new Set<string>(),
|
|
324
472
|
logger,
|
|
325
473
|
};
|
|
326
474
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
HEADER_AMEND,
|
|
10
11
|
HEADER_FORK_OF,
|
|
11
12
|
HEADER_MSG_ID,
|
|
12
13
|
HEADER_PARENT,
|
|
@@ -22,8 +23,9 @@ import {
|
|
|
22
23
|
* @param opts.turnId - Turn correlation ID.
|
|
23
24
|
* @param opts.msgId - Message identity.
|
|
24
25
|
* @param opts.turnClientId - ClientId of the turn initiator.
|
|
25
|
-
* @param opts.parent - Preceding message's msg-id (for branching).
|
|
26
|
+
* @param opts.parent - Preceding message's msg-id (for branching).
|
|
26
27
|
* @param opts.forkOf - Forked message's msg-id (for edit/regen).
|
|
28
|
+
* @param opts.amend - The msg-id of the existing message this message targets (cross-turn events).
|
|
27
29
|
* @returns A headers record with the `x-ably-*` transport headers set.
|
|
28
30
|
*/
|
|
29
31
|
export const buildTransportHeaders = (opts: {
|
|
@@ -31,8 +33,9 @@ export const buildTransportHeaders = (opts: {
|
|
|
31
33
|
turnId: string;
|
|
32
34
|
msgId: string;
|
|
33
35
|
turnClientId?: string;
|
|
34
|
-
parent?: string
|
|
36
|
+
parent?: string;
|
|
35
37
|
forkOf?: string;
|
|
38
|
+
amend?: string;
|
|
36
39
|
}): Record<string, string> => {
|
|
37
40
|
const h: Record<string, string> = {
|
|
38
41
|
[HEADER_ROLE]: opts.role,
|
|
@@ -42,5 +45,6 @@ export const buildTransportHeaders = (opts: {
|
|
|
42
45
|
if (opts.turnClientId !== undefined) h[HEADER_TURN_CLIENT_ID] = opts.turnClientId;
|
|
43
46
|
if (opts.parent) h[HEADER_PARENT] = opts.parent;
|
|
44
47
|
if (opts.forkOf) h[HEADER_FORK_OF] = opts.forkOf;
|
|
48
|
+
if (opts.amend) h[HEADER_AMEND] = opts.amend;
|
|
45
49
|
return h;
|
|
46
50
|
};
|
|
@@ -8,22 +8,30 @@ export type {
|
|
|
8
8
|
ClientTransport,
|
|
9
9
|
ClientTransportOptions,
|
|
10
10
|
CloseOptions,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
LoadHistoryOptions,
|
|
14
|
-
MessageWithHeaders,
|
|
11
|
+
EventsNode,
|
|
12
|
+
MessageNode,
|
|
15
13
|
NewTurnOptions,
|
|
16
|
-
PaginatedMessages,
|
|
17
14
|
SendOptions,
|
|
18
15
|
ServerTransport,
|
|
19
16
|
ServerTransportOptions,
|
|
20
17
|
StreamResponseOptions,
|
|
21
18
|
StreamResult,
|
|
19
|
+
Tree,
|
|
22
20
|
Turn,
|
|
23
21
|
TurnEndReason,
|
|
24
22
|
TurnLifecycleEvent,
|
|
23
|
+
View,
|
|
25
24
|
} from './types.js';
|
|
26
25
|
|
|
26
|
+
// Deprecated aliases — intentional re-export of deprecated types for backwards compatibility.
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
28
|
+
export type { EventNode } from './types.js';
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
30
|
+
export type { TreeNode } from './types.js';
|
|
31
|
+
|
|
32
|
+
// Internal tree interface (consumed by View implementations)
|
|
33
|
+
export type { TreeInternal } from './tree.js';
|
|
34
|
+
|
|
27
35
|
// Server transport
|
|
28
36
|
export { createServerTransport } from './server-transport.js';
|
|
29
37
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Logger } from '../../logger.js';
|
|
9
|
-
import type { StreamEncoder } from '../codec/types.js';
|
|
9
|
+
import type { StreamEncoder, WriteOptions } from '../codec/types.js';
|
|
10
10
|
import type { StreamResult } from './types.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -18,6 +18,7 @@ import type { StreamResult } from './types.js';
|
|
|
18
18
|
* @param encoder - The streaming encoder to write events through.
|
|
19
19
|
* @param signal - Abort signal to monitor for cancellation.
|
|
20
20
|
* @param onAbort - Optional callback invoked when the stream is cancelled, before the stream ends.
|
|
21
|
+
* @param resolveWriteOptions - Optional per-event hook returning {@link WriteOptions} overrides to pass to `encoder.appendEvent`.
|
|
21
22
|
* @param logger - Optional logger for diagnostic output.
|
|
22
23
|
* @returns The reason the pipe ended.
|
|
23
24
|
*/
|
|
@@ -26,6 +27,7 @@ export const pipeStream = async <TEvent, TMessage>(
|
|
|
26
27
|
encoder: StreamEncoder<TEvent, TMessage>,
|
|
27
28
|
signal: AbortSignal | undefined,
|
|
28
29
|
onAbort?: (write: (event: TEvent) => Promise<void>) => void | Promise<void>,
|
|
30
|
+
resolveWriteOptions?: (event: TEvent) => WriteOptions | undefined,
|
|
29
31
|
logger?: Logger,
|
|
30
32
|
): Promise<StreamResult> => {
|
|
31
33
|
logger?.trace('pipeStream();');
|
|
@@ -48,6 +50,7 @@ export const pipeStream = async <TEvent, TMessage>(
|
|
|
48
50
|
new Promise<void>(() => {});
|
|
49
51
|
|
|
50
52
|
let reason: StreamResult['reason'] = 'complete';
|
|
53
|
+
let caughtError: Error | undefined;
|
|
51
54
|
|
|
52
55
|
try {
|
|
53
56
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop broken by return/break
|
|
@@ -73,12 +76,12 @@ export const pipeStream = async <TEvent, TMessage>(
|
|
|
73
76
|
break;
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
await encoder.appendEvent(value);
|
|
79
|
+
await encoder.appendEvent(value, resolveWriteOptions?.(value));
|
|
77
80
|
}
|
|
78
81
|
} catch (error) {
|
|
79
82
|
reason = 'error';
|
|
80
|
-
|
|
81
|
-
logger?.error('pipeStream(); stream error', { error:
|
|
83
|
+
caughtError = error instanceof Error ? error : new Error(String(error));
|
|
84
|
+
logger?.error('pipeStream(); stream error', { error: caughtError.message });
|
|
82
85
|
try {
|
|
83
86
|
await encoder.close();
|
|
84
87
|
} catch {
|
|
@@ -91,5 +94,5 @@ export const pipeStream = async <TEvent, TMessage>(
|
|
|
91
94
|
reader.releaseLock();
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
return { reason };
|
|
97
|
+
return { reason, error: caughtError };
|
|
95
98
|
};
|