diff --git a/packages/api/internal/api/api.gen.go b/packages/api/internal/api/api.gen.go index 924524395a..c341cbadfd 100644 --- a/packages/api/internal/api/api.gen.go +++ b/packages/api/internal/api/api.gen.go @@ -165,6 +165,24 @@ func (e NodeStatus) Valid() bool { } } +// Defines values for OrderDirection. +const ( + Asc OrderDirection = "asc" + Desc OrderDirection = "desc" +) + +// Valid indicates whether the value is a known member of the OrderDirection enum. +func (e OrderDirection) Valid() bool { + switch e { + case Asc: + return true + case Desc: + return true + default: + return false + } +} + // Defines values for SandboxOnTimeout. const ( Kill SandboxOnTimeout = "kill" @@ -716,6 +734,9 @@ type NodeStatusChange struct { Status NodeStatus `json:"status"` } +// OrderDirection Sort direction +type OrderDirection string + // ResumedSandbox defines model for ResumedSandbox. type ResumedSandbox struct { // AutoPause Automatically pauses the sandbox after the timeout @@ -1629,6 +1650,9 @@ type GetV2SandboxesParams struct { // State Filter sandboxes by one or more states State *[]SandboxState `form:"state,omitempty" json:"state,omitempty"` + // Order Sort direction by sandbox start time. Defaults to desc (newest first). + Order *OrderDirection `form:"order,omitempty" json:"order,omitempty"` + // NextToken Cursor to start the list from NextToken *PaginationNextToken `form:"nextToken,omitempty" json:"nextToken,omitempty"` @@ -5406,6 +5430,18 @@ func NewGetV2SandboxesRequest(server string, params *GetV2SandboxesParams) (*htt } + if params.Order != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "order", *params.Order, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.NextToken != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "nextToken", *params.NextToken, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { @@ -13237,6 +13273,14 @@ func (siw *ServerInterfaceWrapper) GetV2Sandboxes(c *gin.Context) { return } + // ------------- Optional query parameter "order" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "order", c.Request.URL.Query(), ¶ms.Order, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandler(c, fmt.Errorf("Invalid format for parameter order: %w", err), http.StatusBadRequest) + return + } + // ------------- Optional query parameter "nextToken" ------------- err = runtime.BindQueryParameterWithOptions("form", true, false, "nextToken", c.Request.URL.Query(), ¶ms.NextToken, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) @@ -13731,192 +13775,193 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ "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", - "vt78/wAAAP//", + "yFBbtRPa+jg6Ojo6Op/fJiFLMpaSVIrJ3rdJhjlOiCQc/sJhSIQ4ZxckPTpUP9B0sjfJsFxOgkmKEzLZ", + "q7UJJpz8K6ecRJM9yXMSTES4JAlWneUqUx2E5DRdTG5uggnO6K9k1T60/Txu1FlO46h1UPt13Jgpi0jr", + "kObjuBEzvKAplpSlxzShUjWKiAg5zdRvk73Je3xNkzxBaZ7MCEdsjqgkiUCSIU5kzlOUEY4yvCCTQEP1", + "r5zwVQlWDOO6UERkjvNYTvZe7ewEkznjCZaTvQlN5evdSTBJ9Izmc0JT81dgwaepJAvCa/B/INcS9r+5", + "hoOcC8YVyEJiLpFcEhRTIdGcs6QF7LQYrhuBAqfRjF237kr5fdzGSIKT1kHNx7EjJlmMJekYtWgwbuRL", + "FudJ+7jF5zGj3qjGImOpIMAEftjZUf8JWSpJCnSKsyymIez99j8Fg30vx/sPTuaTvcn/2i45y7b+Krbf", + "cs64nqNKKG9whBSIRMjJTTD5YefV/c+5n8slSaUZFRHdTk3++v4nf8f4jEYRSfWMP9z/jB+YRHOWp5Ge", + "8af7n/GApfOYhnpHX22Ain5mKVGT/bgJkj0j/JJwSzY39kjBmdn/7eyULKiQfAW3KmcZ4ZLqA4WvxD5c", + "mupyi5pMc/+3M6QboF/JCh0dojnj6O3BKcIVip0E9bMbqLHVxCz1D6u/oasl4QSYsRqVG0gRFShmIZYk", + "ahn6jIScyAJ4/xy6kbuC4eDrH+qjnq8you6/AtDGQCRVF9XvCsbJ18DDKEv297v+GtS3wbtAF6HluGz2", + "T6Kpej9KaPpGSRQHOA1JfEoE3K/1LQ/ha0yiA5annrv+Q3HHg3gikMgBhnkexytU9J40b+JgMsd0xMBy", + "iSXSXdS1rIeeeG94F2e1BVRn/Woxcaav3F9p3IqJgdCay5s0AL6gcexFg/owauAKinXvfjy4s3iQIARd", + "pOfmNj/HC3Fq7rQGHiReCA+l4wUIeBgGUv9Sh9SKB0pgUiKg59YuAMec4xX8jfmCSN8U6vdiTERT9AXk", + "hT2JF18myEiFvYdIDx/ohZSLJ5G7/Oa6HeG8CtdRpE70nOptUsuGpgoVLKSKKaErKpfqiyAIZnVE2Dyn", + "XqblR7MFFYax062B5QZOACi7RIUU4A3HbPE29V4FMbkkcd8NdMwWx9DuJpgkRAgl8TeWdMwWyHxE9t7z", + "4ENIkjU7n0mSKUIosZ5xBuybkxhQbygxZgtEYCk+XNOECIkTzwTn9pNFtjtQsYkRlmRLjdJPfcVUJUoC", + "g80C7WcSy1ycEmzu+xrq9aaYv4qX0e9fAw9miW5ZR4eAGRDXUzh007WdVZLwnNzWPX5v9teeg+r8AQpz", + "zkkq4xXiJGNc0nSBWBrrCxjkFNNjJGU4LLh3ZyzwahcOTj618OODk08oZJwIAA2WovnyxPcs7XiIBkrI", + "TEkozdXjYbQ0ISyXfppkuVR0L0jI0kjAqxSgMZhEqjPCc0k4ulrScOmCisSS5XGEyHVGOekEfKf3XrFQ", + "+oSMA04U0e2XihaPgGHayJ6zp7U1SKpREHTSAtSQMxhMaDSEb7tzDOHRCRYXfYemnOU9Fhc0XRwSiWks", + "VH/92G1c+TghLRA1OZdfe3G+JMhIYBq9PQPV9hRWC8DZGcxaA2e7vpYbfE5wsn9yZATr9fZ3/+QIXZDV", + "+K01E7yBuXEcf5xP9n7v3hMF7yehiPlrMEnzOMazmGj9wmBaMfAOIZML34PjFF+hSxznpDlgY4AYC/lJ", + "EA9cx1iYsy6XVBRIvMIC5QKYnheJ1TU/CGW3LtdHi7qhIUFDmFVKPCQxkWSQANsPmyNQDZTLrPgbARjr", + "C2L20FnR9JCKi/dEchp6JNKIXNLQs5RD+B3ZseoAzGlMxEpIkpx7H63viu9I9UUvyHQxDRC5lj8E6Hou", + "XnpZobouTxj13Znv1TeUqY8WwxGFrfTwM4njNytJfDhW35DIcAiy/wxaucePpvKvP3ifWOostIyqztU6", + "g9alh3L9gd2YBqpdQCprtVt9Rv9N3r/x7CgVF0jQf5O61KFgfk/fjL3Dg8nb9PIzNraSKKJqHhyf1MjL", + "BeFtekk5SxMlXFxiThX78AlBzdP8Nr2MPhMuvLod88HSBUkvI8TzNFUSoJHrW8cOJlrF1bxzWOSha2iM", + "4JsHXU0UtUqzetY+xmUmcsXKd5wlRwleEFfFFlE1dkJTLPVaEpxlakCtcGvjvq6iLpgswqyt4c8HJ05D", + "Xszc0pqkhOO46HETWNyuPhiVv1r1TTBhKRlw1bpg3gTdbV1Ie9vW4VT4dQdoEIUgXJ3K/TBUR/W/hI8a", + "z3QbZBqh/zr7+AFo/OeDkw0oAdUuDlUCepbjE8HreGqgJcNCXDHukS1OzBd1r+WiZD28pKY7x0Ax9lfP", + "4Lkg3H95fzJfhoPqR2oxQ1DixYfVVtGngV4ls5DosxL0TjiZ02sPnuF3kNcUy9M90GWVMep3D+NtIqIz", + "z1k+986jf7/lPFn3IuDBTS12RGNIZBDdGBdE4WOSLuTSI+XC790gtl3MBuDqDIFnX3w4VEzlmApJotZX", + "Oo4p9inq1M9D5MkwpiSVVq+YcaLNGEYw73uF6N7ecbO8UGF0MdJC1XETqKvIEUG6ejnCyo06va3vO3S1", + "JJVrHF3ROPaoHjrfeKQqQnRavZymcIknjK/6F/TetoM+EkdY9hrYDE28t83rpv2+zesQbMDpgIzBKhbI", + "dBqMVSEVTQ5b5Bm0bbgE9C2xUNaDgkproqioQG7ecV6mAF4A8HyAIzZIS2kA/lz27Vd/u14MrvdFcTjd", + "HXHOlkNfldNjj4TFcZWCgatY1bhHcanw1SARe0NGZJYvwAFlzibB5ApzuD9BJPVdmsdsIQ4pJ6H0yt/F", + "J0e/bUxXRks4I8ZrB/bIgjFn/Apz9csMhxfwz8bsweR6S7XfusRwqwrVsQLPu2KUys9viiHNAs5Yzn0v", + "Xf37SNDVbjOOQSrI1JYIsDkMB1/Peu4MU/564gx4E0ze43BJU3KkNqv5TMnyfR4uqSShzDnxK5ux08Iu", + "NNVPCx/Pf4cTGq/8Q83h24BB3rPIR5lqjER9GjrEB6+wVg6TOjoX/1j1N1WxQAfO2nxBA696I67PCU60", + "LsXDVAlOUAIfjZHCsdM01fKOsaj7xm6Yj8wcYyxIjn3qU+qTvTonUaKe6qa1hC+swUDQNCSIZCxcvqw9", + "h1t0KCA/+VXNxv2uqs80TlEksuCY5/yCXpIUqYH5JXYs4tpbsNNgVsWDBQm2N8w6VBkND5j3BycoZOmc", + "LnKufaiaiowWHWn5CHjviBZ1c5f6so6u5tXuf/pw/4FcdRpRbmtI8Gkhv+p5OwTfmF39AfuYEvmHnsAn", + "CMfsqkCBZAUkS4Js5yn6TckzgkjVYI5jQQJEJZqRJb4kVlxICFJCTkZCOl/RdIEikq4+5tBnZwr/296x", + "VJYSecX4hdnlabnkGWMxwSAb4lyyE5wLUrGj6umbHncswerBGscrlKlOVSlGm9pA5DEGsc4ZNYVU5vWx", + "jwOWSs5iM1WKM7FkEl3QNEISq1dQQw5UM2wZ+FhqgUEvwJrKSUwucSp1twIYkMh4Tl6afTAbAGRTDIci", + "zjK7bVta7AGjLEE4jZC5SIU226pWpe4TvcDOX1vQwi7m5d8QJyJPQN8nUcjiaGvGmBToBSfwj5eV9YEc", + "qSStKTrLwyXCJVpCnKZMEY2GGoYlEZqtkOR4PqchAJrkQmrBQH8m11lMQyrjVYAEAxiKcUKWzGhq/TfU", + "qKfQa4oO9a6Bzl+hDr2Y53GMLFrs4trpTg80UJbdLzocADmbJ5FV4PY8h6CZetZoHtn5CgqzWz6AzLkb", + "2PODbl2uSpDQKwmdwe8IxzEyNBWyJMlT6wILG9R4Tzk4H/dsscyw2xLk2uete/qPPglAUUlML73ae3Mh", + "T8er8B/gdWTuhC57791Z/txbSMO7zmwaS6DQk+q2mexN/t/veOvf+1v/d2frpz+2vv6f/xgIiUcE+GAM", + "DTW5Ps6FJHwYqZnGXjGaJd74igP43Q7AeLgkQnIwH7Tax99Z9WSPe6F5joPTzFDrmu5ypr0SyZhZRNFn", + "2EzDTPNtz5Kk+hjrZIROU80QrQm2q5ciB2utLZVBIzw7reWLpe5CKphpMVaJQk0D3lP9c5qG6MxOXmNA", + "/lm00eEoFRKnoZeZWhMKNW1KbXDv/hgXrwFI1g5yZZ+DJU4XQ9RlamrrfHaFBYqxkCjUvQc/xy4HGjO7", + "T6bPz6GJ38DhJgWGmsuuEVtJsc3TWeUILZRTLrJgQ9Xz89VwP2348PmNh0sSgceghyEcUwH8S7eyHt40", + "qhH+cJ/hZ5b7zHI3znKfmeETYYYVZtTPEX2sr2CnPiboeGjVI9ciq40TDUUijk3M0sHJpy5aLdqhwvl4", + "IIUWPbW6ocUDah98l6ozmYfsSDcr1+bo890qA4JLN+rx5y7M8hPCQ+I94QrhavAc/M0z3U472Q8ZO6Li", + "Qvg86qSO4zF7qf3ScbgEVcR2Ujq4DfWldx37vJ70Cv/nvd5wqSawdTZL9/rU7hn3wRnbOg2s7R9XIfYW", + "yqxsbRNAj93NQZDdO3smzwr+2TSv5cLlvtMv6RaKOKbqHtgrOSMVaMbyFPxfZgSJZS5RxK7SKTqS2oqd", + "MgnqzEyilFw5lwpOI91CSJYhpjg/Bqs3FSB0Oy05QRFLNRCK0UWzVRUGPYmklyTW+xCgWS6NosoG1ENo", + "PY5WMHPIUknTnCDgoOnC6r+mX6quNjhSj3C7cmB3EJGg/8jTJcGxXK40h1WADbSRleg/NXOUvxyWs5U/", + "Hrjzlj9/ciAofz2zsFQ2WnP/O3uL9/p0j7+eawfCDKBW8ZFHhNdstEafBCBPGgG8jEsUFR2cKNKisdcW", + "rHWIHb4sVYV4t2nrjlTiD6tFU7vy5Fx7IpZg6pG43mChmIn66ARKF6YXowSnwphg6CweFAtA0st6CE8N", + "IW5oDtwUcD2ml1FVS3u3nj135WqzSYcWswed2ISfSw20QqXZr/LiQJcUo4yz69W0fwfXcHape6u02SGa", + "pFBaXjwGVmASUXn7TRsyMUnVQqLRdpG3pl99sXY8n/K2dZBB1kC7SjMDmsd44V+kNRZpu5rfJGRgadOm", + "3JYTga32yFhb91sstb8tiVwSXlhlraVWPQ1LK1mxYMaV8GsWX+XIU/RBW8NwKpSgokZQYowziiCyg3Qd", + "zHwP/pEbZ9gbcMd8hDdCTOckXIXxULPqcdF+846it7WTPvuZPvuZDvEzNVC+XXAixImSGNqu7rOPB7+e", + "/ailCjiuFnEE+k7Rx1zq9/D5wQkgN09TAslNlpzlCx1irrtr0R9une2IpCs0p7EkCt1/q0ruAuUpvsKc", + "TNEh8MGtBEuwCcxjdgVBrIiThEmCDj+coRf75/998nfNMV/67o/atRlF3HvVVdZqWqlnxJIJuZep15Wm", + "bB37iL5MtKxFrnGSxWQasmTv1c5/7nyZuB4eTpBEa0zNx0y7pyELgY2xeXH67gC9+mn3p5cBSvA12v3x", + "R61smer0djY0YvfHH0dFxtQntC1vNWFdt2PQ3CFkHbusubYXhhYK7o0yFtNwVbjuotnKeerNWVNqrDrU", + "+KWaCivAqesj5BfJWHpePk0HMIKPRfsGfkrw3GG70MUW/kQp2qWz6qEKOp+YpqSBF/jRO4760pVt5YEy", + "ogDAXyt4aMk/M6fEWBbbwlfbbIYlsjeew+ahsArwu/lmDPaqmBb9qWaqilSeg391pB3vG8LGmFu0K6tM", + "zHwR98d3MWfvjQ1zBy4eajj7vHtqUk56sdeXoqdgfmo1osDonWHPtxxnBe8dAXZYGLjt0StbVibx+ty/", + "d73Uh7K0dhPTh6ZxaVicd5jlnwSJTsKWdD9dpqR5zNyUY9aHXQtpYJ1os9xEENLfmneg3W6jOvqTgUCW", + "gFZLTacl6ACHS1+ohvbPMEagFxnwN/Xby/FTdGKjw4TVOagfEe97jFbtQ/45gztGhFw4TxLn3JR74Wy1", + "Q1gO1bpHw+FE1ReuP9jgoy8ZlnUlghYkQhER0qR3NmZaeL4UNjD0FodLgz0lB84Iwujg6PAUzWIWXhQS", + "/39O4X/br3e/TF4GCKMZ5gQdnRTPhVpDaMU4wlaho6Vs08h5OXyZBOjL5C/Tyk8vp2jfLMBmbMPxFV4J", + "8PNHig5JRNSuskvCUURSWjadjnKVAkSd5LOYhucaJ71hCGc65gLRCs9Hn06PhRNqVyqpdBCAdZp3Iv39", + "kraJ42jfW7PccpeEwnS5F8S/04flRmhDa8okEnmmHnhGJQWPU57HY5FIyvf0wDu6+QI3UfsmT9EvTHgw", + "YDGvHqYQsW90G6C1m5FStwae8WZfTOSVN1kerLXruh8jd5hDe5prFVodSbUMAYRvmaMhOU6F4koa9Qhy", + "HOvkhPD8p+nCbuYv5+cn2+r/zoplTdGvZGUt52q88izijE4bR61x0OwJjSGyH4Hi1RraK0ZNy1O2QA9h", + "eZACMyM8oTorfMW4Xntj3LQ/8VzcNZl+BUEufgxaLC5KfBV8x/i5N1/JBdbH7W4By5DlnLtztKzJ8OaO", + "TS9WNyNzxsHge4V5RNNFc1VLgiPCx70Eq4Ap6kJmGAUNTdXaFIdRvJbTiOh0GQbGkgz309KjQ/d3Elwq", + "7g9XAFXryWIckmiKILmEpt0sVrulgRJ/Q0Inn+ZEsDgHi9kSZxlJhbFhbAkFiEGIIGkEjh3MRlSsSX6f", + "MiWbtKkEP1SC6KwlL4c+musU7pOF5edUr1VUslHafYXTXuAo4+ySRiSqjj9FHxMqpaZpeKiiMCaYC0Tl", + "1OtE9ywY3J1g8KQDOv+8gsRjvNYdXlBxfjNcQBGI5QDTUbzro6uZrRsegUW1BMfCYwaxHPiI9ZkyednB", + "bO/1mjKzgmNUa1LJZFhAb6GNZijEGeR0wMgXOloNxfUF1VJhg25JBPqSIo62our2BdRSWcbRBihmQmfb", + "q0T1BgVrNxm1iQhgGpaRFFlPQZbCNoMnBJVG5E8LQ7+9LNEL00EdN610f/k3VwUfGLnWMF7J6WJBuNb9", + "Yz6jkmNexPEGiJM5uJsJEwJsL596WK6PTXQQ1imZcyKWrZscmSvKo3px3VxaEy7PCLogmUQY3NhKtzW3", + "gtHrv1ZKGPkTMLct4MxQz8jcp4WRCK6mwpnDBlZbs+UUHc3dgOvC6GpudCVDA1OPtS8qnHhFNeAdq9Oz", + "24cLLhLZaxGm4A5Oen8hCY4gTAhyAoNAoEZiKZl6FY6taLEG6FGZdgz9W94QdTEHw5DaiyY8+VzeHnP3", + "baJxPTZTX+a3E1zmfWvr60+hC+N1LISI36hctubWLdy3ui7VYT4fnIa2OJXj/luMD0owc6T8eYKMY3Uj", + "T6+6NrRMYmm4cTJpGsa5fioQnOjWoHPGkEJiYeRQ9XFLxPliO1lt2VH2LndfjpJlbMeBXildwC6hUMYU", + "fVJcvoB6G/zgTPYKzWuusCjfEV2LMdeyksXVDXxFBVG3ayzQDIcXlg1xfFXCc3RoRsSz8NXu62KIaS8N", + "OpgIzPb5SPGc4MSj64TigR7ZxiT3tvxZrdOb614cWhGkyxgOBGF8ZMzKakM6kvWQnNl+aMqidP0+PL4R", + "Gi40poydOeYGWe6qvxrMPmekb41e+NMnlDfU4y1qcEd5wkKWmgv8zL1Lmtmzyqi4sosTIVQ77gMMmm5E", + "96lX8vFWldKOZ1CbU4sigwydzxazPouZhw48e2QpD7hAg2eRxPiF92H4rWpoF54LCGbqPZzD+IsZrYe5", + "+E6bhl6v0Dip+13c7RHpiTzSTW9R/sqUuur1JNAKg4qDqeKEqrMcdhZHVA2D8Ouytpt5MKnjr1PmdTn4", + "z8qyUX1c1m6BU2lqXX/4npu0VMBUsFc64j7Qdbp+muO1PdOxkGcZvkpHIwuI4nY37xqO7S0vjg/uY6MA", + "80VdPje+s7Cg4ls07i2RgYG6T4K1DweBdHsw2qTxyrVFz1Ye6dIRbYXal3U5QX1nOjxL1vJn950HbX9Z", + "j5B01zVdCl3v9bIY+QAndbOZLsNwl+Ee8fpZqexPhW1Xz2NQXCCWeqtM0b184P5od9vbHOndFU10bZRZ", + "jbt+4Py3L3A55KFxr7eK0SiucaVs/gaY05SK5bhV2T6Dl7UOqxe3ERoGs6JyUbfnQyXrKTLAtPIVD29q", + "nIR3NCafsphhz5nIOBHeRCMuM5jTGBiB1XubTtZyGRoXuOb5z7nHIfITj51gQBi7NB/kACeozXvxZGFv", + "LNivZVzj+Df1CkNLkwIc63o999YhHeB4XQIwSizhRU3WXgArRVxve9A2cVN4zpXf/b0C4zFbiFu5wN8n", + "KbS5v1dW0Gq0uXX+h3VCjFl4Qbg69R4jY/HNUQq1T7/ObQAM7CDx6AMglQsKlyS8gBherBPjkGsS5roY", + "d0UuKpN/tDILUDh55wKtyB3Ncsf6Z2d/2gjp8+7jIKV19t/F1thY+0H404hoRd3rTtQNUAvVkTlFh0W3", + "ANw4tVOeNi7XYh3d7Pubx/3wuqRTdIBTYy4jCIMhD7TRIYtZigTJMOTZK7zMktWW7ftlol4qlZ/2Ll+B", + "o9nRHEaiwg4dBdqpQ5vspS0lLKwjOMzrGuDs+cQLgYAFD0Jwd0lV7SlgDXTF0bcHfvjdfQ+0XSdjYFy+", + "8z9nRf2arsQirlR5tWSxFZxLARAGAp7I8xRxssA8ioko6L5d2Jzb4pMeXqh+trXzsACfRtG8ZNqZ7NxX", + "2LKL7puVMM0oroK4bnoxUNwCzu/vehOSZH0SlvV+hLZd8zXO1BBJ9UySzCt5eczdTdm2Jz9fAzTrnAN/", + "a++cK0xN6jib0q69GpYF4ZgscLjqsUI82xzuXCZ5thh8pxaDZ339s75+PX29+xYwzwCrT2h9DmzYTnz/", + "vHSMwe2R2tE6pPi6s++thPhNKsKKg9A0LsFyXdoFOcgjzpDML2dZv/pmSBPv1Znt80WeKF5cOmur2ccg", + "EiIJfsHC43SrfrUYhGZFJL0zU/MNMP6Jo4a6k7dNd9nxdqh9VcDdPT3Hi9sryhX5s5DC27n0lJd4MciA", + "NlhgMm9xe9aGe1Phhd8vTY1o/OR60AZLcW6YmjudxqUONWxVtm6KUd14QGrTXj+0G4THs73Kc36jclnW", + "WHn4i7Kj1Iup8eJRXI96bWrbta8CzEZeFg8plj875TwL+YN8PXziSpsk3y+9a46jWeUahf/IVTWqbHT1", + "vzso/deS8NO764cjw42KoYL2MoF6CftptHb53valyJYMvLkSNCD/rtGXqg2A+GwMlRa0KKLH7cpZd4+o", + "ssB/9QX/QiVQKldn6gLQaHJSDqvlgSxGMCf8nT03msP8YascV+H9y19KU9D0L39BlSTF1Tw1inmVl2ZZ", + "zmNGvqScJOxSi8MYzXOI4eUkJlgQiFcqYiZqQ774n39s7Z8cbf1KVv/z8ktqLVCQtwFuOWCBsJ4Sk0sp", + "Qbm6HyU03YdYF7t0qtak005YhO5N/rEFLbfOq3WeTZSMHQiOcfcwqskWbFVziEFg6JV6++dyeaItRvwN", + "rHfYbvYjyRn4NktU5EeNj4ykUl3Jk7e7b9TGOtWh9iY701fTHUgVmpEUZ3SyN3k93ZnumOhDoNltvYgt", + "WISW0opEP91C2YGu16ZDXt3y3a5J82/aFJIWRPfi7e6bP/ZPjv749e1/v3TNnIrfACEeRZO9yQkT0jlO", + "YqJPKhHyDYtWJnxFGs8nyNSiqXj7n8b9RctlvfVPqnXLa1GQRrnKjfwN2NndeXVnsx+YO68OQUeucnNN", + "OmqcGGj2Bw2Wb7YC/G3VSLV9tTOg7SugnB93BrRVjVyWCMrstlP0+9ebr8FE5EmC+coho1r9d1DW/D6p", + "UudXNUuVYre/4RJ9R4c3mnJj4rPQHcLvihprs1VpTzdzqW/fnQJOD8cJkZDWp0VvXzbZrgAI+vsaRf3Q", + "k6Ber+eWm65n6Wv7w2Y23e7EiE1X98K2ksfF9jdtKb/ZxhnduiCrGttqMimBsA4ndUNUdS4YHCNTxw9d", + "MX4BWb1b2JG9mcQ5TK+vGdEkB09EHlANsHkI+y6YfBEtWmU6gcNA+sKZvt4bZ3SE6YdhjHUAPIitxMB6", + "j8gQXrezM/44vR7S9vUGj15dAmthtJVzgAWCg+UePvh7wKHb/qblkUFM99Yn0DBl/xncN4A89FkM+u8C", + "C+mwa6BC4+3XwH3R+KOgW3NX3JputbJgO8RpqLOqt9wY8F2nhKLpVsaZTral3liZSWtX0xXqDGGQK1Dr", + "i/ovD60U1XM9ihukQok7d8bJYd2wWL3WUyLyWPrY+ZlD1UhvUlyU3n6ipKvXDKTkkAwu1IpDSbcIgt+G", + "ZGCttPsrjQ3lNsPz1yDSIh78V5uC7HumUrNatdaBVKo2w62k9kSpVK3YQzTdZOpI3gvSltFQDerybdEg", + "u5+JLOXoW+3vQCtNKVE2jFndm11Ue2kuauS238fzqk259PtXJZR4SKChaatTBWxgffMKUrDb//Um6Hx5", + "GfWQO46f9ThE8PyYuQMlz4aJrO+l4acdl5OMek7UXhNtr4W+18GDCOtPR2dz90zFI8u3chUsQ4/vj7bz", + "9e3/iep8x9t/92ypYbMcxJl2eijPWE+fKa9CeYZuhvEkXda9Vbb5BT7rYAyfRKO/TwbxjCUptB9UIFtP", + "ftR23bjrrMAGi0lZRAbIabqZZzUfzIdO6b+rVj28B/6VE8igZR4EZdn7Tb4BBsmIar23kw41Kjd3YfcJ", + "+LDHdn990jx82/6m/mOuXy+t/Ez0MFDlsJVUPsAoo7mtnnyizvdTJK0+ijKVswfTEaSUN5T4BN+UHxwy", + "qVNc64thidMFQaIIsMIaA773wl1Q2n1ZTlhEdJCYXpDa85uhdwEcL4MB8P+DIZ7EZd6rDNPb66ywhRtV", + "UhsXjKjbGaG4xmr1PojQPi8/vz1H25e75djtrgc/k0oixM5rr6g2CDxIu6FLZqoJu2mwSVl2IxeE/x3P", + "wi/5zs7uX3GW/T3jLIIAZ6jwAfrtNEKXuhRLkguJZgR9Oj1GJA2ZqUbg43xFuW+X8T3IHXoMWf8NGm93", + "mTY29D5NHrcg/yrlBw+grGliqjxf1XTePRobm7q9SPTuOJY2WbF7WO5JeVMQ0mY1N5VpPXK7U3J+0/bn", + "p0qohsacqqweAq1cAttJmQK//QVjGjnRbO4paGfwNr9+D58/YEmCt0zeChJBZTYnOzE6OoS49QWpQDIJ", + "JuQ6i5UMY2ONfGzbDPIHjUSnhaTdDz7B10f646udnRqzDSZ5Sv+VE9MAzsy9Srve+gW3Y/naF9gSwvOx", + "8vN/S4xJQdP9h+ub+WeP1lWbiJxj61O3Fht/ZsccLZoX0AxVudbYsLXIPX5p+UHJBbazmwcH7SqAUkCY", + "rRA8ptv56z2Rwp1zq3We56IUb58JrEpgZyPud4cFbZsqXO0eDqewAaKgwEhXYNC5oRxmQAXSRZEqGaJ0", + "La9ois7Pj1UTiPoi15Kk5lXVId0WlHxgYLwtQd+9pGwgGyUt7zyEtGwTkdoKVjfBQ8nthiK+N5+6h5Xx", + "TSk9sSYTsDk5B6p+1JVkt1R11QqfirKnMfww7U9x5I91mtG1z3vgzbAFhaTqtUAAOrnE0sk7UNw6NEUJ", + "jWNaluXzvifU4H7lt4007a651tBy6dp/TiGYLihboIppQqtQFXkYXu2oV8u4wnAbkAlg19eRCHTa3mfO", + "0CYW2LTG49hCnx7A5QPlE2jA6W7VAdzigBclfPThLrOgYC7tUQf390scB05xxaBWoLNcyD2ddN+wBEor", + "uQd1wNJIGq23sHEgb0R9XauNuK762mUJG1Be/Jk4yJo6jm1TpVsndJGt7kXNgt5u/dx7rirvqSlfe5fk", + "Hi5mimI/wmdJR0X/QU8Uj96nct6sw5N73Lybd58H6oedn4a0/ekpHz5zOGo4Hn0GdSXz1ic+1DGvlTwe", + "8i6Hfo+X/Cvl2ce4BLjPVa3TqLxWwVZsypKb1+wznd+OzjUFrvt85bpIOxFdWixoUuFYWg0Fhe6lMBVO", + "GYrpJRlI/6fFvI/2DNTq1w89BTXpSi9zQzrYx0WZlnDWp01hcxUNzPJxCj3WYMe64yOkRQ1Y9Ph9Ctp1", + "k8+sff0DBOS87vmxddN7M04gjDLCBRUSiiXbUvKFR40Z83+L4rEgJBTOsJX2hb3TrU+Z9jCBaKrSLq6D", + "kmEeNCMrlmquyDhdUAisL6aJ6ZyoS2WolaOA4/HeJhZE5zrZ6Em2NfTTOfPqBOymbz43xZ/AxmD8iAyO", + "Rx9kdRRY3mFoPCNSu77phqUGz774K9yaxjEi1xnlBF1bjZfjPkfLRHnmPEzRAY5jsAAsqUAJkUsWoSSP", + "Jc1iYhJHskvCrziVRq1wfn4cIIJDXdQd5UJ3L/UNpVIOi1LdqFpljKrvDCUEi9yU7rNLi6wKYiBrODe4", + "e7SMwQB4KzFTmP23W2wJ5vksNnVyjuJdFrTReh7dK7Q7JKlo6mTl9aQu+JnIjtuqw+OuFiJSheKd9uEu", + "YZitkGA5D4nj9bdWDpgMq8tZTXMMNrFRXT6Qa2lSxW1GFV655dbVhJeb/qdyyHOosjgOxW/6OECmj2FZ", + "FLzmrHPzYTO5E26fNWGTFFBPx9pHBi0JECpxKqqps3NljpYh5knXS7mLnTk5WNY1TpqMK8+Wye/LMqmI", + "4i7MkhAHvRGb5PA8eU+V15+7uBzBKrYTfN3JLoAaje+Nj3XYiic64MHS9jCG8h5fP/OUR89TAk/AIach", + "lKBR/yKXpEIlEDNoQk9aIgQ5JOBvjzKxBQxDlpp35R9uKI0NVoHN+INjSTx1DO/VO+s9vna54DPXexiu", + "Z50C5QDup6MH14rnLTt72Vr5ccCzr8hC13bYB9fV3dBdb6Iubyl0Wxw95NNrrCh+b/nMSoKxlFr+Vg2N", + "HZH9vjM+1iXS+1B6+eqUD9OF7945DKZsbYtxS1cbgwCAMCSZtJ4Kjy6W7w6p1Z8YrazT4yHCCs/cNqVy", + "tr/BP9qTsxxAaWg6dwr4ULk0sqEusUSuqZDdjNRU2oL/tDDVamZPbFq2CxQtVbJtx02KDnZyWF5RVq2P", + "napbROdNteURVwb60dT76JKGP6wRB0hWNiii90yUpTw7kwSW5hS8sOaYVnFC9ykOwjle3BfDrs6kJhrF", + "tX9oKWL6naYHf1AKLfIV2tL+uBK4UNRY9dsQ96HUper0QrxUbzbcrCzbIS/cIwlqyNYmwVd3DAiJXFC8", + "4gNelFV6nyn87ijc0mg3hVfZ77eyqN/QXK0t8nGd6VaKBY7UTRVdh6cOqNQ6vIuMrd/Zm6nG/lofTd22", + "q1rq+xZScKXQO6KDNe2qa1hwNyG0OrV0Ryq93PcAlWLNmgpP2hRbvXc7nv9l1uHu93+RT7bt5a8GuhfG", + "dn8ahGoF7LWTETfK3bYmJH4CSXwengk3Ke12mqtTYqq+pwP1Vk+DeJ+u+utJq7QsNY3SabkCpK3L9M3U", + "578ZE50AeRTglhvyniom1dfoGz3hPYsYZlk+GWHXzzw1QpdYaAPqd0EmZqPqr99bkkozVUjDhF6Z0SYe", + "GCJ5VmhkrQQga9LJZpOFhDkXYC5+StlCfMlC9Kzm8zp+ABHlJIQ1BAPZvqKKw6JX68AxuYQib4MHPYYO", + "HtSeaf/TIbs/5yxp86OAUUatUk+8Ie08nDk162ANvf+94xz5x6n4aWG2D+zM5GOYt+XTJt/3CE7dVmKg", + "j1Of2cziD8Orj9KIXNsDWoRfFLhsPa5FdghHjvLyErYQH+dzQVqY4+g8St8N+16by26MpbUGivWysmf+", + "tS7/ahQaGMjB5jRWPy2xWHbXQ8EpyrOY4QjFNL2wSk7MkRoBKSLCNHV4AF4R/W2oHPpOtf0Fi+VteZrH", + "eL/Uww613SsoLG+zS+g337+6n9Ok8PIJMN/23nb35WpJOCRUMD/C6TK79B2YkJ7ASYTzYLD/6fR4/JG0", + "Rv+eQBkw9a9jajDm1rs0O92jU+A5Xtw2AsC1+D398vePyMbQbkN1k5IOiH/sKmHwefd7rlITtIVjFoDO", + "VoilBDGOEsZ1RSHAxKCKC1KzhfXSAp5Jr1NuMBFyFasflHTr0+MwLlGhZYB4UhNIqgMlID0DOtSCMtST", + "UP3Ri5RcESHRnHIhX7ZhlfGI8MHi70fVuqa6eCKm0uf6Q4+8/oRiIpe7LztCwDtTMw/K79qmx3WY4hNN", + "3vwk9bF9L/qdsTAXrGkIZltAvhN9bg2VehGgyQF9gwnEynk6Rao3mpGYXemcHroB5gSR6zDOo3bc3pl+", + "+AALsiVIKqiklwSJfKYvVJRgGS4RSwHyhAiBF/pxqlhuyx1JMA+XFbASfH1M0oViALs//nWzrttOTu7P", + "u+sphp+zcw/Pzj2AhfvDusYHcX3efbAwru9M6rnrgLFgsiQ4gq34NvnHlsLAlkZBM/oEri14VRBpgl5T", + "ci1RpjgNm0Myu1iKANG5FtghGKXzDXDzPUZoPrZAuPpBv+9ouOpp35RD0Ofdx+4SZDDxXEJyaOq3QtvS", + "QcD1m6rhue64e4527nQo+ft277wXINoFuGf/0Xv1H137vPQ46o11y/Men4dzzLvnewgwMuoWelx+gQ/7", + "MPE4D/aS8evqA6UnWW+/3PL6QeSW1w8ltxgALKO2gDwuEebPkGelKe687qR7FucJGZhRENnWvmd58en+", + "H656rtHP1hi0/M3V/LkCmyrbaMnC/jKg2r9mfcUofu7nEMO9VPi3FLDZ6GM9634aGcVOD73ZRN5NnD0/", + "09r5lktZTfJ0eNb2N/2P4VHF7USrGxmy/WyGHS1QWngGhhRXiMWGE+MmoTyrnb1Bxt2EEnS5DhddW/2G", + "75MUdh6KIdkcgM9UNixJZxcfAnD5pSWHnMeTvclSykzsbW/jjE7J7myKswwIwPT/VpokyiRr32pZxqs/", + "Ql4892/Yzi2p9rvaMKNbF2RV+c24ahV/FwLP15v/HwAA//8=", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/packages/api/internal/handlers/sandboxes_list.go b/packages/api/internal/handlers/sandboxes_list.go index 55076b0226..1b83c8ae85 100644 --- a/packages/api/internal/handlers/sandboxes_list.go +++ b/packages/api/internal/handlers/sandboxes_list.go @@ -38,6 +38,7 @@ func (a *APIStore) getPausedSandboxes( queryLimit int32, cursorTime time.Time, cursorID string, + order utils.SortDirection, ) ([]utils.PaginatedSandbox, error) { queryMetadata := dbtypes.JSONBStringMap{} if metadataFilter != nil { @@ -49,7 +50,7 @@ func (a *APIStore) getPausedSandboxes( // O(rows × array_size) and caused 40s+ query times with large arrays. dbLimit := queryLimit + int32(len(runningSandboxesIDs)) - snapshots, err := a.throttledGetSnapshots(ctx, queries.GetSnapshotsWithCursorParams{ + snapshots, err := a.throttledGetSnapshots(ctx, order, queries.GetSnapshotsWithCursorParams{ Limit: dbLimit, TeamID: teamID, Metadata: queryMetadata, @@ -149,6 +150,12 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam states = append(states, *params.State...) } + // Sort direction by start time. Defaults to descending (newest first). + order := utils.SortDesc + if params.Order != nil && *params.Order == api.Asc { + order = utils.SortAsc + } + // Initialize pagination pagination, err := utils.NewPagination[utils.PaginatedSandbox]( utils.PaginationParams{ @@ -159,6 +166,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam DefaultLimit: sandboxesDefaultLimit, MaxLimit: sandboxesMaxLimit, DefaultID: utils.MaxSandboxID, + Order: order, }, ) if err != nil { @@ -204,7 +212,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam c.Header("X-Total-Running", strconv.Itoa(len(runningSandboxList))) // Filter based on cursor - runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID()) + runningSandboxList = utils.FilterBasedOnCursor(runningSandboxList, pagination.CursorTime(), pagination.CursorID(), order) sandboxes = append(sandboxes, runningSandboxList...) } @@ -219,7 +227,7 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam runningSandboxesIDs = append(runningSandboxesIDs, info.SandboxID) } - pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID()) + pausedSandboxList, err := a.getPausedSandboxes(ctx, team.ID, runningSandboxesIDs, metadataFilter, pagination.QueryLimit(), pagination.CursorTime(), pagination.CursorID(), order) if err != nil { logger.L().Error(ctx, "Error getting paused sandboxes", zap.Error(err)) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error getting paused sandboxes") @@ -229,14 +237,14 @@ func (a *APIStore) GetV2Sandboxes(c *gin.Context, params api.GetV2SandboxesParam pausingSandboxList := instanceInfoToPaginatedSandboxes(pausingSandboxes) pausingSandboxList = utils.FilterSandboxesOnMetadata(pausingSandboxList, metadataFilter) - pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID()) + pausingSandboxList = utils.FilterBasedOnCursor(pausingSandboxList, pagination.CursorTime(), pagination.CursorID(), order) sandboxes = append(sandboxes, pausedSandboxList...) sandboxes = append(sandboxes, pausingSandboxList...) } // We need to sort again after merging running and paused sandboxes - utils.SortPaginatedSandboxesDesc(sandboxes) + utils.SortPaginatedSandboxes(sandboxes, order) sandboxes = pagination.ProcessResultsWithHeader(c, sandboxes, func(s utils.PaginatedSandbox) (time.Time, string) { return s.PaginationTimestamp, s.SandboxID @@ -329,7 +337,12 @@ func instanceInfoToPaginatedSandboxes(runningSandboxes []sandbox.Sandbox) []util EnvdVersion: info.EnvdVersion, VolumeMounts: convertFromDBMountsToAPIMounts(info.VolumeMounts), }, - PaginationTimestamp: info.StartTime, + // Paused snapshots come from Postgres at microsecond precision, but running + // sandboxes carry nanosecond StartTime from time.Now(). Truncate only the + // pagination key (not the public StartedAt) so the in-memory sort/cursor and + // the SQL predicate agree at the running/paused boundary; otherwise asc + // pagination can re-emit rows that share a truncated microsecond with the cursor. + PaginationTimestamp: info.StartTime.Truncate(time.Microsecond), } if info.Metadata != nil { @@ -358,12 +371,35 @@ func convertFromDBMountsToAPIMounts(mounts []*dbtypes.SandboxVolumeMountConfig) return &results } -// throttledGetSnapshots runs GetSnapshotsWithCursor gated by the sandbox list semaphore. -func (a *APIStore) throttledGetSnapshots(ctx context.Context, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) { +// throttledGetSnapshots runs the cursor snapshot query gated by the sandbox list +// semaphore, picking the ascending or descending keyset query based on the requested +// order. The ascending query returns an identically-shaped row, converted back to the +// descending row type so callers share a single conversion path. +func (a *APIStore) throttledGetSnapshots(ctx context.Context, order utils.SortDirection, params queries.GetSnapshotsWithCursorParams) ([]queries.GetSnapshotsWithCursorRow, error) { if err := a.sandboxListSem.Acquire(ctx, 1); err != nil { return nil, err } defer a.sandboxListSem.Release(1) - return a.sqlcDB.GetSnapshotsWithCursor(ctx, params) + if order != utils.SortAsc { + return a.sqlcDB.GetSnapshotsWithCursor(ctx, params) + } + + ascRows, err := a.sqlcDB.GetSnapshotsWithCursorAsc(ctx, queries.GetSnapshotsWithCursorAscParams{ + Limit: params.Limit, + TeamID: params.TeamID, + Metadata: params.Metadata, + CursorTime: params.CursorTime, + CursorID: params.CursorID, + }) + if err != nil { + return nil, err + } + + rows := make([]queries.GetSnapshotsWithCursorRow, len(ascRows)) + for i, r := range ascRows { + rows[i] = queries.GetSnapshotsWithCursorRow(r) + } + + return rows, nil } diff --git a/packages/api/internal/handlers/sandboxes_list_test.go b/packages/api/internal/handlers/sandboxes_list_test.go new file mode 100644 index 0000000000..25aa738fb2 --- /dev/null +++ b/packages/api/internal/handlers/sandboxes_list_test.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/sandbox" +) + +// TestInstanceInfoToPaginatedSandboxes_PaginationTimestampPrecision guards the keyset +// pagination boundary: running sandboxes carry nanosecond StartTime while paused +// snapshots are microsecond-precision in Postgres. Only the PaginationTimestamp keyset +// value is truncated to microseconds (so the in-memory sort/cursor and the SQL predicate +// agree); the public StartedAt must keep its full precision so list responses match the +// sandbox detail endpoint. +func TestInstanceInfoToPaginatedSandboxes_PaginationTimestampPrecision(t *testing.T) { + t.Parallel() + + // Sub-microsecond bits set (…789 ns) so truncation is observable. + start := time.Date(2026, 1, 2, 3, 4, 5, 123456789, time.UTC) + + sandboxes := instanceInfoToPaginatedSandboxes([]sandbox.Sandbox{ + {SandboxID: "sbx", StartTime: start, State: sandbox.StateRunning}, + }) + + require.Len(t, sandboxes, 1) + + assert.Equal(t, start, sandboxes[0].StartedAt, "public StartedAt must keep full precision") + assert.Equal(t, start.Truncate(time.Microsecond), sandboxes[0].PaginationTimestamp, + "pagination key must be microsecond-aligned") + assert.Zero(t, sandboxes[0].PaginationTimestamp.Nanosecond()%1000, + "pagination key should have no sub-microsecond bits") +} diff --git a/packages/api/internal/utils/pagination.go b/packages/api/internal/utils/pagination.go index 216c4d394f..2a111fe277 100644 --- a/packages/api/internal/utils/pagination.go +++ b/packages/api/internal/utils/pagination.go @@ -16,6 +16,15 @@ func generateCursor(timestamp time.Time, id string) string { return base64.URLEncoding.EncodeToString([]byte(cursor)) } +// SortDirection is the order in which keyset-paginated results are returned. +// The zero value is SortDesc, preserving the default newest-first behavior. +type SortDirection int + +const ( + SortDesc SortDirection = iota + SortAsc +) + // PaginationParams holds pagination parameters from the API request type PaginationParams struct { Limit *int32 @@ -27,6 +36,10 @@ type PaginationConfig struct { DefaultLimit int32 MaxLimit int32 DefaultID string // Default cursor ID when no token is provided (e.g., max UUID or max sandbox ID) + // Order controls the first-page cursor when no token is provided. For + // SortDesc the first page starts at "now" (newest first); for SortAsc it + // starts at the zero time (oldest first). + Order SortDirection } // Cursor represents a parsed pagination cursor @@ -60,7 +73,7 @@ func NewPagination[T any](params PaginationParams, config PaginationConfig) (*Pa // Parse cursor token var err error - p.cursor, err = parseCursorToken(params.NextToken, config.DefaultID) + p.cursor, err = parseCursorToken(params.NextToken, config) if err != nil { return nil, fmt.Errorf("invalid next token: %w", err) } @@ -132,7 +145,7 @@ func (p *Pagination[T]) setHeader(c *gin.Context) { } // parseCursorToken parses a cursor token, returning default values if token is nil/empty -func parseCursorToken(token *string, defaultID string) (Cursor, error) { +func parseCursorToken(token *string, config PaginationConfig) (Cursor, error) { if token != nil && *token != "" { cursorTime, cursorID, err := ParseCursor(*token) if err != nil { @@ -142,6 +155,13 @@ func parseCursorToken(token *string, defaultID string) (Cursor, error) { return Cursor{Time: cursorTime, ID: cursorID}, nil } - // Default to current time and provided default ID to get the first page - return Cursor{Time: time.Now(), ID: defaultID}, nil + // Default cursor for the first page. For descending order we start at "now" + // (so everything older is included); for ascending order we start at the + // zero time (so everything newer is included). + defaultTime := time.Now() + if config.Order == SortAsc { + defaultTime = time.Time{} + } + + return Cursor{Time: defaultTime, ID: config.DefaultID}, nil } diff --git a/packages/api/internal/utils/pagination_test.go b/packages/api/internal/utils/pagination_test.go index 7a95c15c18..72adae311c 100644 --- a/packages/api/internal/utils/pagination_test.go +++ b/packages/api/internal/utils/pagination_test.go @@ -191,6 +191,42 @@ func TestPagination_CursorTime(t *testing.T) { assert.Equal(t, timestamp, p.CursorTime()) } +func TestPagination_DefaultCursorByDirection(t *testing.T) { + t.Parallel() + + t.Run("descending defaults to now", func(t *testing.T) { + t.Parallel() + + before := time.Now() + p, err := NewPagination[testItem](PaginationParams{}, PaginationConfig{ + DefaultLimit: 10, + MaxLimit: 100, + DefaultID: "default-id", + }) + require.NoError(t, err) + after := time.Now() + + assert.False(t, p.CursorTime().Before(before), "default cursor time should be ~now") + assert.False(t, p.CursorTime().After(after), "default cursor time should be ~now") + assert.Equal(t, "default-id", p.CursorID()) + }) + + t.Run("ascending defaults to zero time", func(t *testing.T) { + t.Parallel() + + p, err := NewPagination[testItem](PaginationParams{}, PaginationConfig{ + DefaultLimit: 10, + MaxLimit: 100, + DefaultID: "default-id", + Order: SortAsc, + }) + require.NoError(t, err) + + assert.True(t, p.CursorTime().IsZero(), "ascending default cursor time should be the zero time") + assert.Equal(t, "default-id", p.CursorID()) + }) +} + func TestPagination_CursorID(t *testing.T) { t.Parallel() config := PaginationConfig{ diff --git a/packages/api/internal/utils/sandboxes_list.go b/packages/api/internal/utils/sandboxes_list.go index d16278e9c4..fe334033f8 100644 --- a/packages/api/internal/utils/sandboxes_list.go +++ b/packages/api/internal/utils/sandboxes_list.go @@ -77,14 +77,25 @@ func ParseCursor(cursor string) (time.Time, string, error) { return cursorTime, parts[1], nil } -func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cursorID string) []PaginatedSandbox { - // Apply cursor-based filtering if cursor is provided +// FilterBasedOnCursor keeps only the sandboxes that fall after the cursor in the +// requested order. It compares on PaginationTimestamp (the microsecond-aligned keyset +// value), not the public StartedAt, so running and paused sandboxes share the same +// precision as the SQL predicate. Descending order pages through +// (timestamp DESC, sandbox_id ASC); ascending order is the exact reverse +// (timestamp ASC, sandbox_id DESC). +func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cursorID string, order SortDirection) []PaginatedSandbox { var filteredSandboxes []PaginatedSandbox for _, sandbox := range sandboxes { - // Take sandboxes with start time before cursor time OR - // same start time but sandboxID greater than cursor ID (for stability) - if sandbox.StartedAt.Before(cursorTime) || - (sandbox.StartedAt.Equal(cursorTime) && sandbox.SandboxID > cursorID) { + var include bool + if order == SortAsc { + include = sandbox.PaginationTimestamp.After(cursorTime) || + (sandbox.PaginationTimestamp.Equal(cursorTime) && sandbox.SandboxID < cursorID) + } else { + include = sandbox.PaginationTimestamp.Before(cursorTime) || + (sandbox.PaginationTimestamp.Equal(cursorTime) && sandbox.SandboxID > cursorID) + } + + if include { filteredSandboxes = append(filteredSandboxes, sandbox) } } @@ -92,18 +103,36 @@ func FilterBasedOnCursor(sandboxes []PaginatedSandbox, cursorTime time.Time, cur return filteredSandboxes } -// SortPaginatedSandboxesDesc sorts the sandboxes by StartedAt (descending), -// then by SandboxID (ascending) for stability -func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) { +// SortPaginatedSandboxes sorts the sandboxes by PaginationTimestamp then SandboxID for +// stable pagination. It uses PaginationTimestamp (the microsecond-aligned keyset value), +// not the public StartedAt, so the order matches the cursor filter and SQL predicate. +// Descending order is timestamp DESC, SandboxID ASC; ascending order is the exact +// reverse (timestamp ASC, SandboxID DESC) so it maps onto a backward scan of the +// (team_id, sandbox_started_at DESC, sandbox_id) index. +func SortPaginatedSandboxes(sandboxes []PaginatedSandbox, order SortDirection) { slices.SortFunc(sandboxes, func(a, b PaginatedSandbox) int { - if !a.StartedAt.Equal(b.StartedAt) { - return b.StartedAt.Compare(a.StartedAt) + if !a.PaginationTimestamp.Equal(b.PaginationTimestamp) { + if order == SortAsc { + return a.PaginationTimestamp.Compare(b.PaginationTimestamp) + } + + return b.PaginationTimestamp.Compare(a.PaginationTimestamp) + } + + if order == SortAsc { + return strings.Compare(b.SandboxID, a.SandboxID) } return strings.Compare(a.SandboxID, b.SandboxID) }) } +// SortPaginatedSandboxesDesc preserves the descending-only entry point used by the +// legacy (v1) list endpoint. +func SortPaginatedSandboxesDesc(sandboxes []PaginatedSandbox) { + SortPaginatedSandboxes(sandboxes, SortDesc) +} + func FilterSandboxesOnMetadata(sandboxes []PaginatedSandbox, metadata *map[string]string) []PaginatedSandbox { if metadata == nil { return sandboxes diff --git a/packages/api/internal/utils/sandboxes_list_test.go b/packages/api/internal/utils/sandboxes_list_test.go index 1aab03dd8b..cb7c7dc59d 100644 --- a/packages/api/internal/utils/sandboxes_list_test.go +++ b/packages/api/internal/utils/sandboxes_list_test.go @@ -2,11 +2,107 @@ package utils import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/api" ) +func newPaginatedSandbox(id string, startedAt time.Time) PaginatedSandbox { + return PaginatedSandbox{ + ListedSandbox: api.ListedSandbox{ + SandboxID: id, + StartedAt: startedAt, + }, + PaginationTimestamp: startedAt, + } +} + +func sandboxIDs(sandboxes []PaginatedSandbox) []string { + ids := make([]string, len(sandboxes)) + for i, s := range sandboxes { + ids[i] = s.SandboxID + } + + return ids +} + +func TestSortPaginatedSandboxes(t *testing.T) { + t.Parallel() + + t0 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + t1 := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC) + + // Two sandboxes share t1 to exercise the SandboxID tie-break. + build := func() []PaginatedSandbox { + return []PaginatedSandbox{ + newPaginatedSandbox("b", t1), + newPaginatedSandbox("a", t1), + newPaginatedSandbox("c", t0), + } + } + + t.Run("descending: started_at desc, sandbox_id asc", func(t *testing.T) { + t.Parallel() + + sandboxes := build() + SortPaginatedSandboxes(sandboxes, SortDesc) + assert.Equal(t, []string{"a", "b", "c"}, sandboxIDs(sandboxes)) + }) + + t.Run("ascending: started_at asc, sandbox_id desc", func(t *testing.T) { + t.Parallel() + + sandboxes := build() + SortPaginatedSandboxes(sandboxes, SortAsc) + assert.Equal(t, []string{"c", "b", "a"}, sandboxIDs(sandboxes)) + }) + + t.Run("Desc wrapper matches descending", func(t *testing.T) { + t.Parallel() + + sandboxes := build() + SortPaginatedSandboxesDesc(sandboxes) + assert.Equal(t, []string{"a", "b", "c"}, sandboxIDs(sandboxes)) + }) +} + +func TestFilterBasedOnCursor(t *testing.T) { + t.Parallel() + + t0 := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC) + t1 := time.Date(2024, 1, 1, 11, 0, 0, 0, time.UTC) + t2 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + sandboxes := []PaginatedSandbox{ + newPaginatedSandbox("older", t0), + newPaginatedSandbox("cursor-a", t1), + newPaginatedSandbox("cursor-m", t1), + newPaginatedSandbox("cursor-z", t1), + newPaginatedSandbox("newer", t2), + } + + t.Run("descending keeps older and equal-time greater id", func(t *testing.T) { + t.Parallel() + + // Cursor at (t1, "cursor-m"): next page is everything strictly "after" it + // in started_at DESC, sandbox_id ASC order. + got := FilterBasedOnCursor(sandboxes, t1, "cursor-m", SortDesc) + assert.ElementsMatch(t, []string{"cursor-z", "older"}, sandboxIDs(got)) + }) + + t.Run("ascending keeps newer and equal-time smaller id", func(t *testing.T) { + t.Parallel() + + // Cursor at (t1, "cursor-m"): next page is everything strictly "after" it + // in started_at ASC, sandbox_id DESC order. + got := FilterBasedOnCursor(sandboxes, t1, "cursor-m", SortAsc) + assert.ElementsMatch(t, []string{"cursor-a", "newer"}, sandboxIDs(got)) + }) +} + func TestParseFilters(t *testing.T) { t.Parallel() t.Run("happy path", func(t *testing.T) { diff --git a/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go b/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go index c33b12bfda..747833ceb7 100644 --- a/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go +++ b/packages/db/pkg/tests/snapshots/snapshot_latest_assignment_test.go @@ -119,6 +119,68 @@ func TestGetSnapshotsWithCursor_ReturnsLatestAssignment(t *testing.T) { "GetSnapshotsWithCursor should return the build from the latest assignment") } +// TestGetSnapshotsWithCursorAsc_OrdersOldestFirstAndPaginates verifies the ascending +// keyset query returns snapshots oldest-first and that its cursor predicate walks the +// pages without gaps or overlaps. +func TestGetSnapshotsWithCursorAsc_OrdersOldestFirstAndPaginates(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, teamID) + + // Create three snapshots with strictly increasing sandbox_started_at. + oldestToNewest := make([]string, 0, 3) + for range 3 { + sandboxID := "sandbox-" + uuid.New().String() + snapshotTemplateID := "snapshot-template-" + uuid.New().String() + testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID) + oldestToNewest = append(oldestToNewest, sandboxID) + time.Sleep(10 * time.Millisecond) + } + + // First page (zero-time / MaxSandboxID cursor) mirrors the handler's first-page + // defaults for ascending order. + firstPageCursor := queries.GetSnapshotsWithCursorAscParams{ + TeamID: teamID, + Metadata: types.JSONBStringMap{}, + CursorID: "zzzzzzzzzzzzzzzzzzzz", + CursorTime: pgtype.Timestamptz{Time: time.Time{}, Valid: true}, + Limit: 10, + } + + all, err := db.SqlcClient.GetSnapshotsWithCursorAsc(ctx, firstPageCursor) + require.NoError(t, err) + require.Len(t, all, 3) + assert.Equal(t, []string{ + all[0].Snapshot.SandboxID, + all[1].Snapshot.SandboxID, + all[2].Snapshot.SandboxID, + }, oldestToNewest, "ascending query should return oldest sandbox first") + + // Walk the pages with limit 2: page one is the two oldest... + page1Cursor := firstPageCursor + page1Cursor.Limit = 2 + page1, err := db.SqlcClient.GetSnapshotsWithCursorAsc(ctx, page1Cursor) + require.NoError(t, err) + require.Len(t, page1, 2) + assert.Equal(t, []string{page1[0].Snapshot.SandboxID, page1[1].Snapshot.SandboxID}, oldestToNewest[:2]) + + // ...and the next page (cursor = last returned row) is the newest, with no overlap. + last := page1[1].Snapshot + page2, err := db.SqlcClient.GetSnapshotsWithCursorAsc(ctx, queries.GetSnapshotsWithCursorAscParams{ + TeamID: teamID, + Metadata: types.JSONBStringMap{}, + CursorID: last.SandboxID, + CursorTime: last.SandboxStartedAt, + Limit: 2, + }) + require.NoError(t, err) + require.Len(t, page2, 1) + assert.Equal(t, oldestToNewest[2], page2[0].Snapshot.SandboxID) +} + // TestGetLastSnapshot_BuildSharedWithOtherTemplate verifies that when a build is assigned // to multiple templates, GetLastSnapshot still returns the correct build for this template. func TestGetLastSnapshot_BuildSharedWithOtherTemplate(t *testing.T) { diff --git a/packages/db/queries/get_snapshots_with_cursor.sql b/packages/db/queries/get_snapshots_with_cursor.sql index f6fb005ebf..61dd75bb04 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql +++ b/packages/db/queries/get_snapshots_with_cursor.sql @@ -34,3 +34,43 @@ WHERE AND (s.sandbox_started_at, @cursor_id::text) < (@cursor_time, s.sandbox_id) ORDER BY s.sandbox_started_at DESC, s.sandbox_id ASC LIMIT $1; + +-- name: GetSnapshotsWithCursorAsc :many +-- Ascending counterpart of GetSnapshotsWithCursor. It is the exact reverse order +-- (started_at ASC, sandbox_id DESC) which maps onto a backward scan of the +-- idx_snapshots_team_time_id (team_id, sandbox_started_at DESC, sandbox_id) index. +SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, + sqlc.embed(s), + eb.id AS build_id, + eb.vcpu AS build_vcpu, + eb.ram_mb AS build_ram_mb, + eb.total_disk_size_mb AS build_total_disk_size_mb, + eb.envd_version AS build_envd_version, + eb.created_at AS build_created_at +FROM "public"."snapshots" s +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(alias ORDER BY alias) AS aliases, + ARRAY_AGG(CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END ORDER BY alias) AS names + FROM "public"."env_aliases" + WHERE env_id = s.base_env_id +) ea ON TRUE +JOIN LATERAL ( + SELECT eb.id, eb.vcpu, eb.ram_mb, eb.total_disk_size_mb, eb.envd_version, eb.created_at + FROM "public"."env_build_assignments" eba + JOIN "public"."env_builds" eb ON eb.id = eba.build_id + WHERE + eba.env_id = s.env_id + AND eba.tag = 'default' + AND eb.status_group = 'ready' + ORDER BY eba.created_at DESC + LIMIT 1 +) eb ON TRUE +WHERE + s.team_id = @team_id + AND s.metadata @> @metadata + -- started_at ascending, sandbox_id descending (reverse of the descending query) + AND (s.sandbox_started_at > @cursor_time + OR (s.sandbox_started_at = @cursor_time AND s.sandbox_id < @cursor_id::text)) +ORDER BY s.sandbox_started_at ASC, s.sandbox_id DESC +LIMIT $1; diff --git a/packages/db/queries/get_snapshots_with_cursor.sql.go b/packages/db/queries/get_snapshots_with_cursor.sql.go index 3604d7aad5..8f49bac81a 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql.go +++ b/packages/db/queries/get_snapshots_with_cursor.sql.go @@ -119,3 +119,112 @@ func (q *Queries) GetSnapshotsWithCursor(ctx context.Context, arg GetSnapshotsWi } return items, nil } + +const getSnapshotsWithCursorAsc = `-- name: GetSnapshotsWithCursorAsc :many +SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, + s.created_at, s.env_id, s.sandbox_id, s.id, s.metadata, s.base_env_id, s.sandbox_started_at, s.env_secure, s.origin_node_id, s.allow_internet_access, s.auto_pause, s.team_id, s.config, + eb.id AS build_id, + eb.vcpu AS build_vcpu, + eb.ram_mb AS build_ram_mb, + eb.total_disk_size_mb AS build_total_disk_size_mb, + eb.envd_version AS build_envd_version, + eb.created_at AS build_created_at +FROM "public"."snapshots" s +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(alias ORDER BY alias) AS aliases, + ARRAY_AGG(CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END ORDER BY alias) AS names + FROM "public"."env_aliases" + WHERE env_id = s.base_env_id +) ea ON TRUE +JOIN LATERAL ( + SELECT eb.id, eb.vcpu, eb.ram_mb, eb.total_disk_size_mb, eb.envd_version, eb.created_at + FROM "public"."env_build_assignments" eba + JOIN "public"."env_builds" eb ON eb.id = eba.build_id + WHERE + eba.env_id = s.env_id + AND eba.tag = 'default' + AND eb.status_group = 'ready' + ORDER BY eba.created_at DESC + LIMIT 1 +) eb ON TRUE +WHERE + s.team_id = $2 + AND s.metadata @> $3 + -- started_at ascending, sandbox_id descending (reverse of the descending query) + AND (s.sandbox_started_at > $4 + OR (s.sandbox_started_at = $4 AND s.sandbox_id < $5::text)) +ORDER BY s.sandbox_started_at ASC, s.sandbox_id DESC +LIMIT $1 +` + +type GetSnapshotsWithCursorAscParams struct { + Limit int32 + TeamID uuid.UUID + Metadata types.JSONBStringMap + CursorTime pgtype.Timestamptz + CursorID string +} + +type GetSnapshotsWithCursorAscRow struct { + Aliases []string + Names []string + Snapshot Snapshot + BuildID uuid.UUID + BuildVcpu int64 + BuildRamMb int64 + BuildTotalDiskSizeMb *int64 + BuildEnvdVersion *string + BuildCreatedAt time.Time +} + +// Ascending counterpart of GetSnapshotsWithCursor. It is the exact reverse order +// (started_at ASC, sandbox_id DESC) which maps onto a backward scan of the +// idx_snapshots_team_time_id (team_id, sandbox_started_at DESC, sandbox_id) index. +func (q *Queries) GetSnapshotsWithCursorAsc(ctx context.Context, arg GetSnapshotsWithCursorAscParams) ([]GetSnapshotsWithCursorAscRow, error) { + rows, err := q.db.Query(ctx, getSnapshotsWithCursorAsc, + arg.Limit, + arg.TeamID, + arg.Metadata, + arg.CursorTime, + arg.CursorID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetSnapshotsWithCursorAscRow + for rows.Next() { + var i GetSnapshotsWithCursorAscRow + if err := rows.Scan( + &i.Aliases, + &i.Names, + &i.Snapshot.CreatedAt, + &i.Snapshot.EnvID, + &i.Snapshot.SandboxID, + &i.Snapshot.ID, + &i.Snapshot.Metadata, + &i.Snapshot.BaseEnvID, + &i.Snapshot.SandboxStartedAt, + &i.Snapshot.EnvSecure, + &i.Snapshot.OriginNodeID, + &i.Snapshot.AllowInternetAccess, + &i.Snapshot.AutoPause, + &i.Snapshot.TeamID, + &i.Snapshot.Config, + &i.BuildID, + &i.BuildVcpu, + &i.BuildRamMb, + &i.BuildTotalDiskSizeMb, + &i.BuildEnvdVersion, + &i.BuildCreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/spec/openapi.yml b/spec/openapi.yml index ee4fcb5398..e1c73a2f41 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -254,6 +254,14 @@ components: - running - paused + OrderDirection: + type: string + description: Sort direction + default: desc + enum: + - asc + - desc + SnapshotInfo: type: object required: @@ -2192,6 +2200,12 @@ paths: $ref: "#/components/schemas/SandboxState" style: form explode: false + - name: order + in: query + description: Sort direction by sandbox start time. Defaults to desc (newest first). + required: false + schema: + $ref: "#/components/schemas/OrderDirection" - $ref: "#/components/parameters/paginationNextToken" - $ref: "#/components/parameters/paginationLimit" responses: diff --git a/tests/integration/internal/api/generated.go b/tests/integration/internal/api/generated.go index 41fcb108a7..94597d6834 100644 --- a/tests/integration/internal/api/generated.go +++ b/tests/integration/internal/api/generated.go @@ -160,6 +160,24 @@ func (e NodeStatus) Valid() bool { } } +// Defines values for OrderDirection. +const ( + Asc OrderDirection = "asc" + Desc OrderDirection = "desc" +) + +// Valid indicates whether the value is a known member of the OrderDirection enum. +func (e OrderDirection) Valid() bool { + switch e { + case Asc: + return true + case Desc: + return true + default: + return false + } +} + // Defines values for SandboxOnTimeout. const ( Kill SandboxOnTimeout = "kill" @@ -711,6 +729,9 @@ type NodeStatusChange struct { Status NodeStatus `json:"status"` } +// OrderDirection Sort direction +type OrderDirection string + // ResumedSandbox defines model for ResumedSandbox. type ResumedSandbox struct { // AutoPause Automatically pauses the sandbox after the timeout @@ -1624,6 +1645,9 @@ type GetV2SandboxesParams struct { // State Filter sandboxes by one or more states State *[]SandboxState `form:"state,omitempty" json:"state,omitempty"` + // Order Sort direction by sandbox start time. Defaults to desc (newest first). + Order *OrderDirection `form:"order,omitempty" json:"order,omitempty"` + // NextToken Cursor to start the list from NextToken *PaginationNextToken `form:"nextToken,omitempty" json:"nextToken,omitempty"` @@ -5401,6 +5425,18 @@ func NewGetV2SandboxesRequest(server string, params *GetV2SandboxesParams) (*htt } + if params.Order != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "order", *params.Order, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if params.NextToken != nil { if queryFrag, err := runtime.StyleParamWithOptions("form", true, "nextToken", *params.NextToken, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {