Skip to content

Commit 7014057

Browse files
committed
Fixed consuming realtime runs w/streams after the run is already finished
1 parent 3379fc4 commit 7014057

File tree

8 files changed

+416
-46
lines changed

8 files changed

+416
-46
lines changed

packages/core/src/v3/apiClient/runStream.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,11 +116,14 @@ export function runShapeStream<TRunTypes extends AnyRunTypes>(
116116
{ once: true }
117117
);
118118

119+
const runStreamInstance = zodShapeStream(SubscribeRunRawShape, url, {
120+
...options,
121+
signal: abortController.signal,
122+
});
123+
119124
const $options: RunSubscriptionOptions = {
120-
runShapeStream: zodShapeStream(SubscribeRunRawShape, url, {
121-
...options,
122-
signal: abortController.signal,
123-
}),
125+
runShapeStream: runStreamInstance.stream,
126+
stopRunShapeStream: runStreamInstance.stop,
124127
streamFactory: new VersionedStreamSubscriptionFactory(version1, version2),
125128
abortController,
126129
...options,
@@ -215,7 +218,7 @@ export class ElectricStreamSubscription implements StreamSubscription {
215218

216219
async subscribe(): Promise<ReadableStream<unknown>> {
217220
return zodShapeStream(SubscribeRealtimeStreamChunkRawShape, this.url, this.options)
218-
.pipeThrough(
221+
.stream.pipeThrough(
219222
new TransformStream({
220223
transform(chunk, controller) {
221224
controller.enqueue(chunk.value);
@@ -298,12 +301,12 @@ export interface RunShapeProvider {
298301

299302
export type RunSubscriptionOptions = RunShapeStreamOptions & {
300303
runShapeStream: ReadableStream<SubscribeRunRawShape>;
304+
stopRunShapeStream: () => void;
301305
streamFactory: StreamSubscriptionFactory;
302306
abortController: AbortController;
303307
};
304308

305309
export class RunSubscription<TRunTypes extends AnyRunTypes> {
306-
private unsubscribeShape?: () => void;
307310
private stream: AsyncIterableStream<RunShape<TRunTypes>>;
308311
private packetCache = new Map<string, any>();
309312
private _closeOnComplete: boolean;
@@ -330,7 +333,7 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
330333
) {
331334
console.log("Closing stream because run is complete");
332335

333-
this.options.abortController.abort();
336+
this.options.stopRunShapeStream();
334337
}
335338
},
336339
},
@@ -342,7 +345,7 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
342345
if (!this.options.abortController.signal.aborted) {
343346
this.options.abortController.abort();
344347
}
345-
this.unsubscribeShape?.();
348+
this.options.stopRunShapeStream();
346349
}
347350

348351
[Symbol.asyncIterator](): AsyncIterator<RunShape<TRunTypes>> {

packages/core/src/v3/apiClient/stream.ts

Lines changed: 61 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,40 @@ export type ZodShapeStreamOptions = {
1616
signal?: AbortSignal;
1717
};
1818

19+
export type ZodShapeStreamInstance<TShapeSchema extends z.ZodTypeAny> = {
20+
stream: AsyncIterableStream<z.output<TShapeSchema>>;
21+
stop: () => void;
22+
};
23+
1924
export function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
2025
schema: TShapeSchema,
2126
url: string,
2227
options?: ZodShapeStreamOptions
23-
) {
24-
const stream = new ShapeStream<z.input<TShapeSchema>>({
28+
): ZodShapeStreamInstance<TShapeSchema> {
29+
const abortController = new AbortController();
30+
31+
options?.signal?.addEventListener(
32+
"abort",
33+
() => {
34+
abortController.abort();
35+
},
36+
{ once: true }
37+
);
38+
39+
const shapeStream = new ShapeStream({
2540
url,
2641
headers: {
2742
...options?.headers,
2843
"x-trigger-electric-version": "1.0.0-beta.1",
2944
},
3045
fetchClient: options?.fetchClient,
31-
signal: options?.signal,
46+
signal: abortController.signal,
3247
});
3348

34-
const readableShape = new ReadableShapeStream(stream);
49+
const readableShape = new ReadableShapeStream(shapeStream);
3550

36-
return readableShape.stream.pipeThrough(
37-
new TransformStream({
51+
const stream = readableShape.stream.pipeThrough(
52+
new TransformStream<unknown, z.output<TShapeSchema>>({
3853
async transform(chunk, controller) {
3954
const result = schema.safeParse(chunk);
4055

@@ -46,6 +61,14 @@ export function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
4661
},
4762
})
4863
);
64+
65+
return {
66+
stream: stream as AsyncIterableStream<z.output<TShapeSchema>>,
67+
stop: () => {
68+
console.log("Stopping zodShapeStream with abortController.abort()");
69+
abortController.abort();
70+
},
71+
};
4972
}
5073

5174
export type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
@@ -104,14 +127,19 @@ class ReadableShapeStream<T extends Row<unknown> = Row> {
104127
readonly #currentState: Map<string, T> = new Map();
105128
readonly #changeStream: AsyncIterableStream<T>;
106129
#error: FetchError | false = false;
130+
#unsubscribe?: () => void;
131+
132+
stop() {
133+
this.#unsubscribe?.();
134+
}
107135

108136
constructor(stream: ShapeStreamInterface<T>) {
109137
this.#stream = stream;
110138

111139
// Create the source stream that will receive messages
112140
const source = new ReadableStream<Message<T>[]>({
113141
start: (controller) => {
114-
this.#stream.subscribe(
142+
this.#unsubscribe = this.#stream.subscribe(
115143
(messages) => controller.enqueue(messages),
116144
this.#handleError.bind(this)
117145
);
@@ -121,41 +149,44 @@ class ReadableShapeStream<T extends Row<unknown> = Row> {
121149
// Create the transformed stream that processes messages and emits complete rows
122150
this.#changeStream = createAsyncIterableStream(source, {
123151
transform: (messages, controller) => {
124-
messages.forEach((message) => {
152+
const updatedKeys = new Set<string>();
153+
154+
for (const message of messages) {
125155
if (isChangeMessage(message)) {
156+
const key = message.key;
126157
switch (message.headers.operation) {
127158
case "insert": {
128-
this.#currentState.set(message.key, message.value);
129-
controller.enqueue(message.value);
159+
// New row entirely
160+
this.#currentState.set(key, message.value);
161+
updatedKeys.add(key);
130162
break;
131163
}
132164
case "update": {
133-
const existingRow = this.#currentState.get(message.key);
134-
if (existingRow) {
135-
const updatedRow = {
136-
...existingRow,
137-
...message.value,
138-
};
139-
this.#currentState.set(message.key, updatedRow);
140-
controller.enqueue(updatedRow);
141-
} else {
142-
this.#currentState.set(message.key, message.value);
143-
controller.enqueue(message.value);
144-
}
165+
// Merge updates into existing row if any, otherwise treat as new
166+
const existingRow = this.#currentState.get(key);
167+
const updatedRow = existingRow
168+
? { ...existingRow, ...message.value }
169+
: message.value;
170+
this.#currentState.set(key, updatedRow);
171+
updatedKeys.add(key);
145172
break;
146173
}
147174
}
175+
} else if (isControlMessage(message)) {
176+
if (message.headers.control === "must-refetch") {
177+
this.#currentState.clear();
178+
this.#error = false;
179+
}
148180
}
181+
}
149182

150-
if (isControlMessage(message)) {
151-
switch (message.headers.control) {
152-
case "must-refetch":
153-
this.#currentState.clear();
154-
this.#error = false;
155-
break;
156-
}
183+
// Now enqueue only one updated row per key, after all messages have been processed.
184+
for (const key of updatedKeys) {
185+
const finalRow = this.#currentState.get(key);
186+
if (finalRow) {
187+
controller.enqueue(finalRow);
157188
}
158-
});
189+
}
159190
},
160191
});
161192
}

packages/react-hooks/src/hooks/useRealtime.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,13 @@ export function useRealtimeRun<TTask extends AnyTask>(
109109
}
110110
}, [runId, mutateRun, abortControllerRef, apiClient, setError]);
111111

112+
const hasCalledOnCompleteRef = useRef(false);
113+
112114
// Effect to handle onComplete callback
113115
useEffect(() => {
114-
if (isComplete && options?.onComplete && run) {
116+
if (isComplete && run && options?.onComplete && !hasCalledOnCompleteRef.current) {
115117
options.onComplete(run, error);
118+
hasCalledOnCompleteRef.current = true;
116119
}
117120
}, [isComplete, run, error, options?.onComplete]);
118121

@@ -261,10 +264,13 @@ export function useRealtimeRunWithStreams<
261264
}
262265
}, [runId, mutateRun, mutateStreams, streamsRef, abortControllerRef, apiClient, setError]);
263266

267+
const hasCalledOnCompleteRef = useRef(false);
268+
264269
// Effect to handle onComplete callback
265270
useEffect(() => {
266-
if (isComplete && options?.onComplete && run) {
271+
if (isComplete && run && options?.onComplete && !hasCalledOnCompleteRef.current) {
267272
options.onComplete(run, error);
273+
hasCalledOnCompleteRef.current = true;
268274
}
269275
}, [isComplete, run, error, options?.onComplete]);
270276

@@ -593,7 +599,7 @@ async function processRealtimeRunWithStreams<
593599
nextStreamData[type] = [...(existingDataRef.current[type] || []), ...chunks];
594600
}
595601

596-
await mutateStreamData(nextStreamData);
602+
mutateStreamData(nextStreamData);
597603
}, throttleInMs);
598604

599605
for await (const part of subscription.withStreams<TStreams>()) {

0 commit comments

Comments
 (0)