11import { json } from "@remix-run/server-runtime" ;
2- import Redis , { Callback , Result , type RedisOptions } from "ioredis" ;
2+ import { tryCatch } from "@trigger.dev/core/utils" ;
3+ import { safeParseNaturalLanguageDurationAgo } from "@trigger.dev/core/v3/isomorphic" ;
4+ import { Callback , Result } from "ioredis" ;
35import { randomUUID } from "node:crypto" ;
6+ import { createRedisClient , RedisClient , RedisWithClusterOptions } from "~/redis.server" ;
47import { longPollingFetch } from "~/utils/longPollingFetch" ;
58import { logger } from "./logger.server" ;
6- import { createRedisClient , RedisClient , RedisWithClusterOptions } from "~/redis.server" ;
79
810export interface CachedLimitProvider {
911 getCachedLimit : ( organizationId : string , defaultValue : number ) => Promise < number | undefined > ;
1012}
1113
14+ const MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS = 7 * 24 * 60 * 60 * 1000 ;
15+
1216export type RealtimeClientOptions = {
1317 electricOrigin : string ;
1418 redis : RedisWithClusterOptions ;
@@ -24,6 +28,7 @@ export type RealtimeEnvironment = {
2428
2529export type RealtimeRunsParams = {
2630 tags ?: string [ ] ;
31+ createdAt ?: string ;
2732} ;
2833
2934export class RealtimeClient {
@@ -92,9 +97,91 @@ export class RealtimeClient {
9297 whereClauses . push ( `"runTags" @> ARRAY[${ params . tags . map ( ( t ) => `'${ t } '` ) . join ( "," ) } ]` ) ;
9398 }
9499
100+ const createdAtFilter = await this . #calculateCreatedAtFilter( url , params . createdAt ) ;
101+
102+ if ( createdAtFilter ) {
103+ whereClauses . push ( `"createdAt" > '${ createdAtFilter . toISOString ( ) } '` ) ;
104+ }
105+
95106 const whereClause = whereClauses . join ( " AND " ) ;
96107
97- return this . #streamRunsWhere( url , environment , whereClause , clientVersion ) ;
108+ const response = await this . #streamRunsWhere( url , environment , whereClause , clientVersion ) ;
109+
110+ if ( createdAtFilter ) {
111+ const [ setCreatedAtFilterError ] = await tryCatch (
112+ this . #setCreatedAtFilterFromResponse( response , createdAtFilter )
113+ ) ;
114+
115+ if ( setCreatedAtFilterError ) {
116+ logger . error ( "[realtimeClient] Failed to set createdAt filter" , {
117+ error : setCreatedAtFilterError ,
118+ createdAtFilter,
119+ responseHeaders : Object . fromEntries ( response . headers . entries ( ) ) ,
120+ responseStatus : response . status ,
121+ } ) ;
122+ }
123+ }
124+
125+ return response ;
126+ }
127+
128+ async #calculateCreatedAtFilter( url : URL | string , createdAt ?: string ) {
129+ const duration = createdAt ?? "24h" ;
130+ const $url = new URL ( url . toString ( ) ) ;
131+ const shapeId = extractShapeId ( $url ) ;
132+
133+ if ( ! shapeId ) {
134+ // This means we need to calculate the createdAt filter and store it in redis after we get back the response
135+ const createdAtFilter = safeParseNaturalLanguageDurationAgo ( duration ) ;
136+
137+ // Validate that the createdAt filter is in the past, and not more than 1 week in the past.
138+ // if it's more than 1 week in the past, just return 1 week ago Date
139+ if (
140+ createdAtFilter &&
141+ createdAtFilter < new Date ( Date . now ( ) - MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS )
142+ ) {
143+ return new Date ( Date . now ( ) - MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS ) ;
144+ }
145+
146+ return createdAtFilter ;
147+ } else {
148+ // We need to get the createdAt filter value from redis, if there is none we need to return undefined
149+ const [ createdAtFilterError , createdAtFilter ] = await tryCatch (
150+ this . #getCreatedAtFilter( shapeId )
151+ ) ;
152+
153+ if ( createdAtFilterError ) {
154+ logger . error ( "[realtimeClient] Failed to get createdAt filter" , {
155+ shapeId,
156+ error : createdAtFilterError ,
157+ } ) ;
158+
159+ return ;
160+ }
161+
162+ return createdAtFilter ;
163+ }
164+ }
165+
166+ async #getCreatedAtFilter( shapeId : string ) {
167+ // TODO: replace this with unkey cache so we can use in memory
168+ const createdAtFilterRawValue = await this . redis . get ( `shapes:${ shapeId } :filters:createdAt` ) ;
169+
170+ if ( ! createdAtFilterRawValue ) {
171+ return ;
172+ }
173+
174+ return new Date ( createdAtFilterRawValue ) ;
175+ }
176+
177+ async #setCreatedAtFilterFromResponse( response : Response , createdAtFilter : Date ) {
178+ const shapeId = extractShapeIdFromResponse ( response ) ;
179+
180+ if ( ! shapeId ) {
181+ return ;
182+ }
183+
184+ await this . redis . set ( `shapes:${ shapeId } :filters:createdAt` , createdAtFilter . toISOString ( ) ) ;
98185 }
99186
100187 async #streamRunsWhere(
@@ -172,6 +259,8 @@ export class RealtimeClient {
172259 ) {
173260 const shapeId = extractShapeId ( url ) ;
174261
262+ url = await this . #handleCreatedAtFilter( url , shapeId ) ;
263+
175264 logger . debug ( "[realtimeClient] request" , {
176265 url : url . toString ( ) ,
177266 } ) ;
@@ -232,6 +321,10 @@ export class RealtimeClient {
232321 // ... (rest of your existing code for the long polling request)
233322 const response = await longPollingFetch ( url . toString ( ) , { signal } , rewriteResponseHeaders ) ;
234323
324+ // If this is the initial request, the response.headers['electric-handle'] will be the shapeId
325+ // And we may need to set the "createdAt" filter timestamp keyed by the shapeId
326+ // Then in the next request, we will get the createdAt timestamp value via the shapeId and use it to filter the results
327+
235328 // Decrement the counter after the long polling request is complete
236329 await this . #decrementConcurrency( environment . id , requestId ) ;
237330
@@ -244,6 +337,10 @@ export class RealtimeClient {
244337 }
245338 }
246339
340+ async #handleCreatedAtFilter( url : URL , shapeId ?: string | null ) {
341+ return url ;
342+ }
343+
247344 async #incrementAndCheck( environmentId : string , requestId : string , limit : number ) {
248345 const key = this . #getKey( environmentId ) ;
249346 const now = Date . now ( ) ;
@@ -314,7 +411,11 @@ export class RealtimeClient {
314411}
315412
316413function extractShapeId ( url : URL ) {
317- return url . searchParams . get ( "handle" ) ;
414+ return url . searchParams . get ( "handle" ) ?? url . searchParams . get ( "shape_id" ) ;
415+ }
416+
417+ function extractShapeIdFromResponse ( response : Response ) {
418+ return response . headers . get ( "electric-handle" ) ;
318419}
319420
320421function isLiveRequestUrl ( url : URL ) {
0 commit comments