@@ -16,25 +16,40 @@ export type ZodShapeStreamOptions = {
16
16
signal ?: AbortSignal ;
17
17
} ;
18
18
19
+ export type ZodShapeStreamInstance < TShapeSchema extends z . ZodTypeAny > = {
20
+ stream : AsyncIterableStream < z . output < TShapeSchema > > ;
21
+ stop : ( ) => void ;
22
+ } ;
23
+
19
24
export function zodShapeStream < TShapeSchema extends z . ZodTypeAny > (
20
25
schema : TShapeSchema ,
21
26
url : string ,
22
27
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 ( {
25
40
url,
26
41
headers : {
27
42
...options ?. headers ,
28
43
"x-trigger-electric-version" : "1.0.0-beta.1" ,
29
44
} ,
30
45
fetchClient : options ?. fetchClient ,
31
- signal : options ? .signal ,
46
+ signal : abortController . signal ,
32
47
} ) ;
33
48
34
- const readableShape = new ReadableShapeStream ( stream ) ;
49
+ const readableShape = new ReadableShapeStream ( shapeStream ) ;
35
50
36
- return readableShape . stream . pipeThrough (
37
- new TransformStream ( {
51
+ const stream = readableShape . stream . pipeThrough (
52
+ new TransformStream < unknown , z . output < TShapeSchema > > ( {
38
53
async transform ( chunk , controller ) {
39
54
const result = schema . safeParse ( chunk ) ;
40
55
@@ -46,6 +61,14 @@ export function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
46
61
} ,
47
62
} )
48
63
) ;
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
+ } ;
49
72
}
50
73
51
74
export type AsyncIterableStream < T > = AsyncIterable < T > & ReadableStream < T > ;
@@ -104,14 +127,19 @@ class ReadableShapeStream<T extends Row<unknown> = Row> {
104
127
readonly #currentState: Map < string , T > = new Map ( ) ;
105
128
readonly #changeStream: AsyncIterableStream < T > ;
106
129
#error: FetchError | false = false ;
130
+ #unsubscribe?: ( ) => void ;
131
+
132
+ stop ( ) {
133
+ this . #unsubscribe?.( ) ;
134
+ }
107
135
108
136
constructor ( stream : ShapeStreamInterface < T > ) {
109
137
this . #stream = stream ;
110
138
111
139
// Create the source stream that will receive messages
112
140
const source = new ReadableStream < Message < T > [ ] > ( {
113
141
start : ( controller ) => {
114
- this . #stream. subscribe (
142
+ this . #unsubscribe = this . # stream. subscribe (
115
143
( messages ) => controller . enqueue ( messages ) ,
116
144
this . #handleError. bind ( this )
117
145
) ;
@@ -121,41 +149,44 @@ class ReadableShapeStream<T extends Row<unknown> = Row> {
121
149
// Create the transformed stream that processes messages and emits complete rows
122
150
this . #changeStream = createAsyncIterableStream ( source , {
123
151
transform : ( messages , controller ) => {
124
- messages . forEach ( ( message ) => {
152
+ const updatedKeys = new Set < string > ( ) ;
153
+
154
+ for ( const message of messages ) {
125
155
if ( isChangeMessage ( message ) ) {
156
+ const key = message . key ;
126
157
switch ( message . headers . operation ) {
127
158
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 ) ;
130
162
break ;
131
163
}
132
164
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 ) ;
145
172
break ;
146
173
}
147
174
}
175
+ } else if ( isControlMessage ( message ) ) {
176
+ if ( message . headers . control === "must-refetch" ) {
177
+ this . #currentState. clear ( ) ;
178
+ this . #error = false ;
179
+ }
148
180
}
181
+ }
149
182
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 ) ;
157
188
}
158
- } ) ;
189
+ }
159
190
} ,
160
191
} ) ;
161
192
}
0 commit comments