@@ -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+
1924export 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
5174export 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 }
0 commit comments