From b395388caa9fc18346175c6c55dd366c449d3d58 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 23 Apr 2026 09:54:30 -0700 Subject: [PATCH 1/2] feat: version list endpoint can filter with CEL expression --- apps/api/openapi/openapi.json | 10 +++++ .../openapi/paths/deploymentversions.jsonnet | 1 + .../src/routes/v1/workspaces/deployments.ts | 38 +++++++++++++------ apps/api/src/types/openapi.ts | 2 + 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index 1860ff727..8f92c07d6 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -5006,6 +5006,16 @@ ], "type": "string" } + }, + { + "allowReserved": true, + "description": "CEL expression to filter the results", + "in": "query", + "name": "cel", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { diff --git a/apps/api/openapi/paths/deploymentversions.jsonnet b/apps/api/openapi/paths/deploymentversions.jsonnet index 3004b5981..74a94d931 100644 --- a/apps/api/openapi/paths/deploymentversions.jsonnet +++ b/apps/api/openapi/paths/deploymentversions.jsonnet @@ -11,6 +11,7 @@ local openapi = import '../lib/openapi.libsonnet'; openapi.limitParam(), openapi.offsetParam(), openapi.orderParam(), + openapi.celParam(), ], responses: openapi.paginatedResponse(openapi.schemaRef('DeploymentVersion')) + openapi.notFoundResponse() diff --git a/apps/api/src/routes/v1/workspaces/deployments.ts b/apps/api/src/routes/v1/workspaces/deployments.ts index d0c91cd1b..952b58151 100644 --- a/apps/api/src/routes/v1/workspaces/deployments.ts +++ b/apps/api/src/routes/v1/workspaces/deployments.ts @@ -1,9 +1,10 @@ import type { AsyncTypedHandler } from "@/types/api.js"; import { ApiError, asyncHandler } from "@/types/api.js"; +import { evaluate } from "cel-js"; import { Router } from "express"; import { v4 as uuidv4 } from "uuid"; -import { and, asc, count, desc, eq, inArray, takeFirst } from "@ctrlplane/db"; +import { and, asc, desc, eq, inArray, takeFirst } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; import { enqueueDeploymentPlan, @@ -258,6 +259,19 @@ const deleteDeployment: AsyncTypedHandler< .json({ id: deploymentId, message: "Deployment delete requested" }); }; +function filterDeploymentVersions( + versions: (typeof schema.deploymentVersion.$inferSelect)[], + cel: string, +) { + return versions.filter((version) => { + try { + return evaluate(cel, { deploymentVersion: version }); + } catch { + return false; + } + }); +} + const listDeploymentVersions: AsyncTypedHandler< "/v1/workspaces/{workspaceId}/deployments/{deploymentId}/versions", "get" @@ -266,15 +280,12 @@ const listDeploymentVersions: AsyncTypedHandler< const limit = req.query.limit ?? 50; const offset = req.query.offset ?? 0; const order = req.query.order ?? "desc"; + const { cel } = req.query; - const [countResult] = await db - .select({ total: count() }) - .from(schema.deploymentVersion) - .where(eq(schema.deploymentVersion.deploymentId, deploymentId)); - - const total = countResult?.total ?? 0; + if (cel != null && !validResourceSelector(cel)) + throw new ApiError("Invalid CEL expression", 400); - const versions = await db + const allVersions = await db .select() .from(schema.deploymentVersion) .where(eq(schema.deploymentVersion.deploymentId, deploymentId)) @@ -283,11 +294,16 @@ const listDeploymentVersions: AsyncTypedHandler< ? asc(schema.deploymentVersion.createdAt) : desc(schema.deploymentVersion.createdAt), ) - .limit(limit) - .offset(offset); + .limit(1000); + + const filtered = + cel != null ? filterDeploymentVersions(allVersions, cel) : allVersions; + + const total = filtered.length; + const items = filtered.slice(offset, offset + limit); res.status(200).json({ - items: versions.map(formatDeploymentVersion), + items: items.map(formatDeploymentVersion), total, limit, offset, diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index c2d090ab0..15a356629 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -3285,6 +3285,8 @@ export interface operations { offset?: number; /** @description Sort order for results */ order?: "asc" | "desc"; + /** @description CEL expression to filter the results */ + cel?: string; }; header?: never; path: { From 08309e59b799ad2fbda497e3d3dbad463089f94d Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 23 Apr 2026 10:25:06 -0700 Subject: [PATCH 2/2] cleanup --- .../src/routes/v1/workspaces/deployments.ts | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/api/src/routes/v1/workspaces/deployments.ts b/apps/api/src/routes/v1/workspaces/deployments.ts index 952b58151..67b91ecbb 100644 --- a/apps/api/src/routes/v1/workspaces/deployments.ts +++ b/apps/api/src/routes/v1/workspaces/deployments.ts @@ -4,7 +4,7 @@ import { evaluate } from "cel-js"; import { Router } from "express"; import { v4 as uuidv4 } from "uuid"; -import { and, asc, desc, eq, inArray, takeFirst } from "@ctrlplane/db"; +import { and, asc, count, desc, eq, inArray, takeFirst } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; import { enqueueDeploymentPlan, @@ -282,29 +282,52 @@ const listDeploymentVersions: AsyncTypedHandler< const order = req.query.order ?? "desc"; const { cel } = req.query; - if (cel != null && !validResourceSelector(cel)) + const orderBy = + order === "asc" + ? asc(schema.deploymentVersion.createdAt) + : desc(schema.deploymentVersion.createdAt); + + if (cel == null) { + const { total } = await db + .select({ total: count() }) + .from(schema.deploymentVersion) + .where(eq(schema.deploymentVersion.deploymentId, deploymentId)) + .then(takeFirst); + + const versions = await db + .select() + .from(schema.deploymentVersion) + .where(eq(schema.deploymentVersion.deploymentId, deploymentId)) + .orderBy(orderBy) + .limit(limit) + .offset(offset); + + res.status(200).json({ + items: versions.map(formatDeploymentVersion), + total, + limit, + offset, + }); + return; + } + + if (!validResourceSelector(cel)) throw new ApiError("Invalid CEL expression", 400); - const allVersions = await db + // CEL is evaluated in-memory, so cap the candidate set to bound cost. + // Filtering applies to the 1000 most-recent (or oldest, for asc) versions. + const candidates = await db .select() .from(schema.deploymentVersion) .where(eq(schema.deploymentVersion.deploymentId, deploymentId)) - .orderBy( - order === "asc" - ? asc(schema.deploymentVersion.createdAt) - : desc(schema.deploymentVersion.createdAt), - ) + .orderBy(orderBy) .limit(1000); - const filtered = - cel != null ? filterDeploymentVersions(allVersions, cel) : allVersions; - - const total = filtered.length; - const items = filtered.slice(offset, offset + limit); + const filtered = filterDeploymentVersions(candidates, cel); res.status(200).json({ - items: items.map(formatDeploymentVersion), - total, + items: filtered.slice(offset, offset + limit).map(formatDeploymentVersion), + total: filtered.length, limit, offset, });