1+ import { PLAN_LIMITS , PlanId } from "@stackframe/stack-shared/dist/plans" ;
12import { wait } from "@stackframe/stack-shared/dist/utils/promises" ;
23import { deindent } from "@stackframe/stack-shared/dist/utils/strings" ;
34import { it } from "../../../../helpers" ;
4- import { Project , User , niceBackendFetch } from "../../../backend-helpers" ;
5+ import { backendContext , InternalProjectKeys , Project , User , niceBackendFetch } from "../../../backend-helpers" ;
56
67async function runQuery ( body : { query : string , params ?: Record < string , string > , timeout_ms ?: number } ) {
78 await Project . createAndSwitch ( { config : { magic_link_enabled : true } } ) ;
@@ -15,6 +16,33 @@ async function runQuery(body: { query: string, params?: Record<string, string>,
1516 return response ;
1617}
1718
19+ async function runQueryWithPlan ( planId : PlanId , body : { query : string , params ?: Record < string , string > , timeout_ms ?: number } ) {
20+ const { createProjectResponse, adminAccessToken } = await Project . createAndSwitch ( { config : { magic_link_enabled : true } } ) ;
21+ const ownerTeamId = createProjectResponse . body . owner_team_id ;
22+
23+ if ( planId !== "free" ) {
24+ const savedKeys = backendContext . value . projectKeys ;
25+ backendContext . set ( { projectKeys : InternalProjectKeys } ) ;
26+ const grantResponse = await niceBackendFetch ( `/api/v1/payments/products/team/${ ownerTeamId } ` , {
27+ method : "POST" ,
28+ accessType : "server" ,
29+ body : { product_id : planId } ,
30+ } ) ;
31+ if ( grantResponse . status !== 200 ) {
32+ throw new Error ( `Failed to grant plan '${ planId } ' to team '${ ownerTeamId } ': ${ JSON . stringify ( grantResponse . body ) } ` ) ;
33+ }
34+ backendContext . set ( { projectKeys : savedKeys } ) ;
35+ }
36+
37+ const response = await niceBackendFetch ( "/api/v1/internal/analytics/query" , {
38+ method : "POST" ,
39+ accessType : "admin" ,
40+ body,
41+ } ) ;
42+
43+ return response ;
44+ }
45+
1846type ExpectLike = ( ( value : unknown ) => { toEqual : ( value : unknown ) => void } ) & {
1947 any : ( constructor : unknown ) => unknown ,
2048} ;
@@ -154,10 +182,11 @@ it("can execute a query with custom timeout", async ({ expect }) => {
154182 ` ) ;
155183} ) ;
156184
157- it ( "rejects timeouts longer than 2 minutes" , async ( { expect } ) => {
185+ it ( "rejects timeouts longer than max plan limit" , async ( { expect } ) => {
186+ const maxSchemaMs = Math . max ( ...Object . values ( PLAN_LIMITS ) . map ( p => p . analyticsTimeoutSeconds ) ) * 1000 ;
158187 const response = await runQuery ( {
159188 query : "SELECT 1 as value" ,
160- timeout_ms : 120_001 ,
189+ timeout_ms : maxSchemaMs + 1 ,
161190 } ) ;
162191
163192 expect ( stripQueryId ( response , expect ) ) . toMatchInlineSnapshot ( `
@@ -168,12 +197,12 @@ it("rejects timeouts longer than 2 minutes", async ({ expect }) => {
168197 "details": {
169198 "message": deindent\`
170199 Request validation failed on POST /api/v1/internal/analytics/query:
171- - body.timeout_ms must be less than or equal to 120000
200+ - body.timeout_ms must be less than or equal to ${ maxSchemaMs }
172201 \`,
173202 },
174203 "error": deindent\`
175204 Request validation failed on POST /api/v1/internal/analytics/query:
176- - body.timeout_ms must be less than or equal to 120000
205+ - body.timeout_ms must be less than or equal to ${ maxSchemaMs }
177206 \`,
178207 },
179208 "headers": Headers {
@@ -1524,6 +1553,51 @@ it("does not allow input() function", async ({ expect }) => {
15241553 ` ) ;
15251554} ) ;
15261555
1556+ it ( "clamps timeout to free plan limit" , async ( { expect } ) => {
1557+ const response = await runQueryWithPlan ( "free" , {
1558+ query : "SELECT getSetting('max_execution_time') as max_execution_time" ,
1559+ timeout_ms : 120000 ,
1560+ } ) ;
1561+
1562+ expect ( response . status ) . toBe ( 200 ) ;
1563+ const maxExecutionTime = Number ( ( response . body ?. result as any ) ?. [ 0 ] ?. max_execution_time ) ;
1564+ expect ( maxExecutionTime ) . toBe ( PLAN_LIMITS . free . analyticsTimeoutSeconds ) ;
1565+ } ) ;
1566+
1567+ it ( "clamps timeout to team plan limit" , async ( { expect } ) => {
1568+ const response = await runQueryWithPlan ( "team" , {
1569+ query : "SELECT getSetting('max_execution_time') as max_execution_time" ,
1570+ timeout_ms : 120000 ,
1571+ } ) ;
1572+
1573+ expect ( response . status ) . toBe ( 200 ) ;
1574+ const maxExecutionTime = Number ( ( response . body ?. result as any ) ?. [ 0 ] ?. max_execution_time ) ;
1575+ expect ( maxExecutionTime ) . toBe ( PLAN_LIMITS . team . analyticsTimeoutSeconds ) ;
1576+ } ) ;
1577+
1578+ it ( "clamps timeout to growth plan limit" , async ( { expect } ) => {
1579+ const maxSchemaMs = Math . max ( ...Object . values ( PLAN_LIMITS ) . map ( p => p . analyticsTimeoutSeconds ) ) * 1000 ;
1580+ const response = await runQueryWithPlan ( "growth" , {
1581+ query : "SELECT getSetting('max_execution_time') as max_execution_time" ,
1582+ timeout_ms : maxSchemaMs ,
1583+ } ) ;
1584+
1585+ expect ( response . status ) . toBe ( 200 ) ;
1586+ const maxExecutionTime = Number ( ( response . body ?. result as any ) ?. [ 0 ] ?. max_execution_time ) ;
1587+ expect ( maxExecutionTime ) . toBe ( PLAN_LIMITS . growth . analyticsTimeoutSeconds ) ;
1588+ } ) ;
1589+
1590+ it ( "does not clamp timeout below the plan limit" , async ( { expect } ) => {
1591+ const response = await runQueryWithPlan ( "team" , {
1592+ query : "SELECT getSetting('max_execution_time') as max_execution_time" ,
1593+ timeout_ms : 5000 ,
1594+ } ) ;
1595+
1596+ expect ( response . status ) . toBe ( 200 ) ;
1597+ const maxExecutionTime = Number ( ( response . body ?. result as any ) ?. [ 0 ] ?. max_execution_time ) ;
1598+ expect ( maxExecutionTime ) . toBe ( 5 ) ;
1599+ } ) ;
1600+
15271601it ( "does not allow numbers table function with large values" , async ( { expect } ) => {
15281602 const response = await runQuery ( {
15291603 query : "SELECT * FROM numbers(1000000000)" ,
0 commit comments