Unified · Split
| @@ -1161,12 +1161,18 @@ export declare const internal: { · 1160 unmodified lines | ||
| 1161 | 1161 | { buildId: string; cursor?: string; workspaceId: Id<"workspaces"> }, |
| 1162 | 1162 | any |
| 1163 | 1163 | >; |
| 1164 | − finishBuild: FunctionReference< | |
| 1164 | + failBuild: FunctionReference< | |
| 1165 | 1165 | "mutation", |
| 1166 | 1166 | "internal", |
| 1167 | 1167 | { buildId: string; workspaceId: Id<"workspaces"> }, |
| 1168 | 1168 | any |
| 1169 | 1169 | >; |
| 1170 | + finishBuild: FunctionReference< | |
| 1171 | + "mutation", | |
| 1172 | + "internal", | |
| 1173 | + { buildId: string; signature: string; workspaceId: Id<"workspaces"> }, | |
| 1174 | + any | |
| 1175 | + >; | |
| 1170 | 1176 | getState: FunctionReference< |
| 1171 | 1177 | "query", |
| 1172 | 1178 | "internal", |
| @@ -1274,6 +1280,17 @@ export declare const internal: { · 101 unmodified lines | ||
| 1274 | 1280 | }, |
| 1275 | 1281 | any |
| 1276 | 1282 | >; |
| 1283 | + updateBuildMetadata: FunctionReference< | |
| 1284 | + "mutation", | |
| 1285 | + "internal", | |
| 1286 | + { | |
| 1287 | + buildId: string; | |
| 1288 | + typesStorageId?: Id<"_storage">; | |
| 1289 | + warnings: Array<string>; | |
| 1290 | + workspaceId: Id<"workspaces">; | |
| 1291 | + }, | |
| 1292 | + any | |
| 1293 | + >; | |
| 1277 | 1294 | }; |
| 1278 | 1295 | workspaceAuthInternal: { |
| 1279 | 1296 | getWorkspaceAccessForAnonymousSubject: FunctionReference< |
| @@ -2,6 +2,7 @@ import { authedMutation } from "../../core/src/function-builders"; · 1 unmodified line | ||
| 2 | 2 | import { deleteCurrentAccountHandler } from "../src/accounts/delete-current-account"; |
| 3 | 3 | |
| 4 | 4 | export const deleteCurrentAccount = authedMutation({ |
| 5 | + method: "POST", | |
| 5 | 6 | args: {}, |
| 6 | 7 | handler: deleteCurrentAccountHandler, |
| 7 | 8 | }); |
| @@ -1,9 +1,9 @@ | ||
| 1 | −import { query } from "./_generated/server"; | |
| 2 | −import { optionalAccountQuery } from "../../core/src/function-builders"; | |
| 1 | +import { customQuery, optionalAccountQuery } from "../../core/src/function-builders"; | |
| 3 | 2 | |
| 4 | 3 | const workosEnabled = Boolean(process.env.WORKOS_CLIENT_ID && process.env.WORKOS_API_KEY); |
| 5 | 4 | |
| 6 | −export const getClientConfig = query({ | |
| 5 | +export const getClientConfig = customQuery({ | |
| 6 | + method: "GET", | |
| 7 | 7 | args: {}, |
| 8 | 8 | handler: async () => { |
| 9 | 9 | return { |
| @@ -19,6 +19,7 @@ export const getClientConfig = query({ · 9 unmodified lines | ||
| 19 | 19 | }); |
| 20 | 20 | |
| 21 | 21 | export const getCurrentAccount = optionalAccountQuery({ |
| 22 | + method: "GET", | |
| 22 | 23 | args: {}, |
| 23 | 24 | handler: async (ctx) => ctx.account, |
| 24 | 25 | }); |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { AuthKit, type AuthFunctions } from "@convex-dev/workos-authkit"; |
| 2 | 2 | import { components, internal } from "./_generated/api"; |
| 3 | 3 | import type { DataModel } from "./_generated/dataModel.d.ts"; |
| 4 | −import { internalMutation, mutation } from "./_generated/server"; | |
| 4 | +import { internalMutation } from "./_generated/server"; | |
| 5 | +import { customMutation } from "../../core/src/function-builders"; | |
| 5 | 6 | import { bootstrapCurrentWorkosAccountImpl } from "../src/auth/bootstrap"; |
| 6 | 7 | import { workosEventHandlers } from "../src/auth/event_handlers"; |
| 7 | 8 | |
| @@ -39,7 +40,8 @@ export const authKitEvent = authKitEvents?.authKitEvent ?? internalMutation({ · 31 unmodified lines | ||
| 39 | 40 | handler: async () => null, |
| 40 | 41 | }); |
| 41 | 42 | |
| 42 | −export const bootstrapCurrentWorkosAccount = mutation({ | |
| 43 | +export const bootstrapCurrentWorkosAccount = customMutation({ | |
| 44 | + method: "POST", | |
| 43 | 45 | args: {}, |
| 44 | 46 | handler: bootstrapCurrentWorkosAccountImpl, |
| 45 | 47 | }); |
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | import { StripeSubscriptions } from "@convex-dev/stripe"; |
| 2 | 2 | import { v } from "convex/values"; |
| 3 | 3 | import { components, internal } from "./_generated/api"; |
| 4 | −import { action } from "./_generated/server"; | |
| 5 | −import { organizationMutation, organizationQuery } from "../../core/src/function-builders"; | |
| 4 | +import { customAction, organizationMutation, organizationQuery } from "../../core/src/function-builders"; | |
| 6 | 5 | import { |
| 7 | 6 | createCustomerPortalHandler, |
| 8 | 7 | createSubscriptionCheckoutHandler, |
| @@ -13,13 +12,15 @@ import { · 4 unmodified lines | ||
| 13 | 12 | const stripeClient = new StripeSubscriptions(components.stripe, {}); |
| 14 | 13 | |
| 15 | 14 | export const getSummary = organizationQuery({ |
| 15 | + method: "GET", | |
| 16 | 16 | args: {}, |
| 17 | 17 | handler: async (ctx) => { |
| 18 | 18 | return await getBillingSummaryHandler(ctx, components); |
| 19 | 19 | }, |
| 20 | 20 | }); |
| 21 | 21 | |
| 22 | −export const createSubscriptionCheckout = action({ | |
| 22 | +export const createSubscriptionCheckout = customAction({ | |
| 23 | + method: "POST", | |
| 23 | 24 | args: { |
| 24 | 25 | organizationId: v.id("organizations"), |
| 25 | 26 | priceId: v.string(), |
| @@ -32,7 +33,8 @@ export const createSubscriptionCheckout = action({ · 6 unmodified lines | ||
| 32 | 33 | }, |
| 33 | 34 | }); |
| 34 | 35 | |
| 35 | −export const createCustomerPortal = action({ | |
| 36 | +export const createCustomerPortal = customAction({ | |
| 37 | + method: "POST", | |
| 36 | 38 | args: { |
| 37 | 39 | organizationId: v.id("organizations"), |
| 38 | 40 | returnUrl: v.optional(v.string()), |
| @@ -47,6 +49,7 @@ export const createCustomerPortal = action({ · 8 unmodified lines | ||
| 47 | 49 | }); |
| 48 | 50 | |
| 49 | 51 | export const retrySeatSync = organizationMutation({ |
| 52 | + method: "POST", | |
| 50 | 53 | requireBillingAdmin: true, |
| 51 | 54 | args: {}, |
| 52 | 55 | handler: async (ctx) => { |
| @@ -2,7 +2,7 @@ · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { v } from "convex/values"; |
| 4 | 4 | import { internal } from "./_generated/api"; |
| 5 | −import { action } from "./_generated/server"; | |
| 5 | +import { customAction } from "../../core/src/function-builders"; | |
| 6 | 6 | import { upsertCredentialHandler } from "../src/credentials-node/upsert-credential"; |
| 7 | 7 | import { |
| 8 | 8 | credentialProviderValidator, |
| @@ -10,7 +10,8 @@ import { · 1 unmodified line | ||
| 10 | 10 | jsonObjectValidator, |
| 11 | 11 | } from "../src/database/validators"; |
| 12 | 12 | |
| 13 | −export const upsertCredential = action({ | |
| 13 | +export const upsertCredential = customAction({ | |
| 14 | + method: "POST", | |
| 14 | 15 | args: { |
| 15 | 16 | id: v.optional(v.string()), |
| 16 | 17 | workspaceId: v.id("workspaces"), |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { v } from "convex/values"; |
| 2 | 2 | import { internal } from "./_generated/api"; |
| 3 | −import { action, internalMutation } from "./_generated/server"; | |
| 4 | −import { workspaceMutation } from "../../core/src/function-builders"; | |
| 3 | +import { internalMutation } from "./_generated/server"; | |
| 4 | +import { customAction, workspaceMutation } from "../../core/src/function-builders"; | |
| 5 | 5 | import { |
| 6 | 6 | completeRuntimeRunHandler, |
| 7 | 7 | createTaskHandler, |
| @@ -11,7 +11,8 @@ import { · 3 unmodified lines | ||
| 11 | 11 | } from "../src/executor/handlers"; |
| 12 | 12 | import { jsonObjectValidator } from "../src/database/validators"; |
| 13 | 13 | |
| 14 | −export const createTask = action({ | |
| 14 | +export const createTask = customAction({ | |
| 15 | + method: "POST", | |
| 15 | 16 | args: { |
| 16 | 17 | code: v.string(), |
| 17 | 18 | timeoutMs: v.optional(v.number()), |
| @@ -45,6 +46,7 @@ export const createTaskInternal = internalMutation({ · 27 unmodified lines | ||
| 45 | 46 | }); |
| 46 | 47 | |
| 47 | 48 | export const resolveApproval = workspaceMutation({ |
| 49 | + method: "POST", | |
| 48 | 50 | args: { |
| 49 | 51 | approvalId: v.string(), |
| 50 | 52 | decision: v.union(v.literal("approved"), v.literal("denied")), |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use node"; |
| 2 | 2 | |
| 3 | 3 | import { v } from "convex/values"; |
| 4 | −import { action, internalAction } from "./_generated/server"; | |
| 4 | +import { internalAction } from "./_generated/server"; | |
| 5 | 5 | import type { |
| 6 | 6 | ToolCallResult, |
| 7 | 7 | ToolDescriptor, |
| @@ -18,8 +18,10 @@ import { · 10 unmodified lines | ||
| 18 | 18 | import { runQueuedTask } from "../src/runtime/task_runner"; |
| 19 | 19 | import { handleExternalToolCallRequest } from "../src/runtime/external_tool_call"; |
| 20 | 20 | import { jsonObjectValidator } from "../src/database/validators"; |
| 21 | +import { customAction } from "../../core/src/function-builders"; | |
| 21 | 22 | |
| 22 | −export const listToolsWithWarnings = action({ | |
| 23 | +export const listToolsWithWarnings = customAction({ | |
| 24 | + method: "POST", | |
| 23 | 25 | args: { |
| 24 | 26 | workspaceId: v.id("workspaces"), |
| 25 | 27 | accountId: v.optional(v.id("accounts")), |
| @@ -4,6 +4,7 @@ import { components } from "./_generated/api"; · 3 unmodified lines | ||
| 4 | 4 | import { authKit } from "./auth"; |
| 5 | 5 | import { anonymousJwksHandler, anonymousTokenHandler } from "./http/anonymous_auth"; |
| 6 | 6 | import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler"; |
| 7 | +import { openApiHandler } from "./http/openapi_handler"; | |
| 7 | 8 | import { |
| 8 | 9 | oauthAuthorizationServerHandler, |
| 9 | 10 | oauthProtectedResourceHandler, |
| @@ -29,4 +30,9 @@ http.route({ path: "/.well-known/jwks.json", method: "GET", handler: anonymousJw · 19 unmodified lines | ||
| 29 | 30 | http.route({ path: "/auth/anonymous/token", method: "POST", handler: anonymousTokenHandler }); |
| 30 | 31 | http.route({ path: "/auth/anonymous/token", method: "GET", handler: anonymousTokenHandler }); |
| 31 | 32 | |
| 33 | +http.route({ path: "/api", method: "POST", handler: openApiHandler }); | |
| 34 | +http.route({ path: "/api", method: "GET", handler: openApiHandler }); | |
| 35 | +http.route({ pathPrefix: "/api/", method: "POST", handler: openApiHandler }); | |
| 36 | +http.route({ pathPrefix: "/api/", method: "GET", handler: openApiHandler }); | |
| 37 | + | |
| 32 | 38 | export default http; |
| @@ -0,0 +1,339 @@ | ||
| 1 | +import { makeFunctionReference } from "convex/server"; | |
| 2 | +import { httpAction } from "../_generated/server"; | |
| 3 | +import { buildOpenApiDocument } from "./openapi_spec"; | |
| 4 | +import { collectPublicFunctionSpecs } from "./openapi_spec_registry"; | |
| 5 | + | |
| 6 | +type FunctionType = "query" | "mutation" | "action"; | |
| 7 | + | |
| 8 | +type OpenApiPayload = { | |
| 9 | + path?: string; | |
| 10 | + function?: string; | |
| 11 | + args?: unknown; | |
| 12 | + format?: string; | |
| 13 | + type?: string; | |
| 14 | + [key: string]: unknown; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +type HttpActionCtx = Parameters<Parameters<typeof httpAction>[0]>[0]; | |
| 18 | + | |
| 19 | +type ActionError = Error & { | |
| 20 | + data?: unknown; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +const JSON_FORMAT = "json"; | |
| 24 | +const OPENAPI_SPEC_PATH = "/api/openapi.json"; | |
| 25 | +const API_RUN_PREFIX = "/api/run/"; | |
| 26 | +const API_PREFIX = "/api/"; | |
| 27 | +const CONTROL_QUERY_PARAMS = new Set(["format", "path", "function", "type", "args"]); | |
| 28 | + | |
| 29 | +function parseRunPathToFunctionIdentifier(rawPath: string): string { | |
| 30 | + const normalizedPath = rawPath.replace(/^\/+|\/+$/g, ""); | |
| 31 | + if (!normalizedPath) { | |
| 32 | + throw new Error("Function path is required"); | |
| 33 | + } | |
| 34 | + | |
| 35 | + if (normalizedPath.includes(":")) { | |
| 36 | + const [modulePathRaw, exportNameRaw] = decodeURIComponent(normalizedPath).split(":"); | |
| 37 | + if (!modulePathRaw) { | |
| 38 | + throw new Error("Function path is required"); | |
| 39 | + } | |
| 40 | + | |
| 41 | + const modulePath = modulePathRaw.endsWith(".ts") | |
| 42 | + ? modulePathRaw.slice(0, -3) | |
| 43 | + : modulePathRaw; | |
| 44 | + | |
| 45 | + if (!exportNameRaw || exportNameRaw === "default") { | |
| 46 | + return modulePath; | |
| 47 | + } | |
| 48 | + | |
| 49 | + return `${modulePath}:${exportNameRaw}`; | |
| 50 | + } | |
| 51 | + | |
| 52 | + if (normalizedPath.endsWith(".ts")) { | |
| 53 | + return decodeURIComponent(normalizedPath.slice(0, -3)); | |
| 54 | + } | |
| 55 | + | |
| 56 | + const segments = normalizedPath.split("/").filter(Boolean).map((segment) => decodeURIComponent(segment)); | |
| 57 | + if (segments.length === 0) { | |
| 58 | + throw new Error("Function path is required"); | |
| 59 | + } | |
| 60 | + | |
| 61 | + if (segments.length === 1) { | |
| 62 | + return segments[0]!; | |
| 63 | + } | |
| 64 | + | |
| 65 | + const exportName = segments[segments.length - 1]!; | |
| 66 | + const modulePath = segments.slice(0, -1).join("/"); | |
| 67 | + return exportName === "default" ? modulePath : `${modulePath}:${exportName}`; | |
| 68 | +} | |
| 69 | + | |
| 70 | +function functionIdentifierFromUrlPath(pathname: string): string | undefined { | |
| 71 | + if (pathname.startsWith(API_RUN_PREFIX)) { | |
| 72 | + return parseRunPathToFunctionIdentifier(pathname.slice(API_RUN_PREFIX.length)); | |
| 73 | + } | |
| 74 | + | |
| 75 | + if (pathname === "/api" || pathname === "/api/") { | |
| 76 | + return undefined; | |
| 77 | + } | |
| 78 | + | |
| 79 | + if (pathname.startsWith(API_PREFIX)) { | |
| 80 | + return parseRunPathToFunctionIdentifier(pathname.slice(API_PREFIX.length)); | |
| 81 | + } | |
| 82 | + | |
| 83 | + return undefined; | |
| 84 | +} | |
| 85 | + | |
| 86 | +function toActionError(error: unknown): ActionError { | |
| 87 | + if (error instanceof Error) { | |
| 88 | + return error as ActionError; | |
| 89 | + } | |
| 90 | + return new Error("Unknown function error"); | |
| 91 | +} | |
| 92 | + | |
| 93 | +function isTypeLookupError(error: unknown, type: FunctionType): boolean { | |
| 94 | + const message = toActionError(error).message; | |
| 95 | + return message.startsWith(`No ${type} named`) | |
| 96 | + || message.startsWith("Could not find public function") | |
| 97 | + || message.startsWith("Could not find function") | |
| 98 | + || message.includes(`is not a ${type}`) | |
| 99 | + || message.includes("Function not found"); | |
| 100 | +} | |
| 101 | + | |
| 102 | +function isLookupError(error: unknown): boolean { | |
| 103 | + const message = toActionError(error).message; | |
| 104 | + return message.startsWith("No query named") | |
| 105 | + || message.startsWith("No mutation named") | |
| 106 | + || message.startsWith("No action named") | |
| 107 | + || message.startsWith("Could not find public function") | |
| 108 | + || message.startsWith("Could not find function") | |
| 109 | + || message.includes("is not a query") | |
| 110 | + || message.includes("is not a mutation") | |
| 111 | + || message.includes("is not an action") | |
| 112 | + || message.includes("Function not found"); | |
| 113 | +} | |
| 114 | + | |
| 115 | +function errorResponse(errorMessage: string, status = 400): Response { | |
| 116 | + return Response.json( | |
| 117 | + { | |
| 118 | + errorMessage, | |
| 119 | + }, | |
| 120 | + { status }, | |
| 121 | + ); | |
| 122 | +} | |
| 123 | + | |
| 124 | +function functionErrorResponse(error: unknown, status?: number): Response { | |
| 125 | + const actionError = toActionError(error); | |
| 126 | + const resolvedStatus = status ?? (isLookupError(actionError) ? 404 : 500); | |
| 127 | + | |
| 128 | + return Response.json( | |
| 129 | + { | |
| 130 | + errorMessage: actionError.message, | |
| 131 | + ...(actionError.data === undefined ? {} : { errorData: actionError.data }), | |
| 132 | + }, | |
| 133 | + { status: resolvedStatus }, | |
| 134 | + ); | |
| 135 | +} | |
| 136 | + | |
| 137 | +function successResponse(value: unknown): Response { | |
| 138 | + return Response.json(value === undefined ? null : value, { status: 200 }); | |
| 139 | +} | |
| 140 | + | |
| 141 | +function isRecord(value: unknown): value is Record<string, unknown> { | |
| 142 | + return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 143 | +} | |
| 144 | + | |
| 145 | +function isFunctionType(value: unknown): value is FunctionType { | |
| 146 | + return value === "query" || value === "mutation" || value === "action"; | |
| 147 | +} | |
| 148 | + | |
| 149 | +function parseQueryValue(rawValue: string): unknown { | |
| 150 | + const value = rawValue.trim(); | |
| 151 | + const looksJson = value.startsWith("{") | |
| 152 | + || value.startsWith("[") | |
| 153 | + || value === "true" | |
| 154 | + || value === "false" | |
| 155 | + || value === "null" | |
| 156 | + || /^-?\d+(?:\.\d+)?$/.test(value) | |
| 157 | + || /^".*"$/.test(value); | |
| 158 | + | |
| 159 | + if (looksJson) { | |
| 160 | + try { | |
| 161 | + return JSON.parse(value); | |
| 162 | + } catch { | |
| 163 | + return rawValue; | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + return rawValue; | |
| 168 | +} | |
| 169 | + | |
| 170 | +function mergeQueryParam(args: Record<string, unknown>, key: string, value: unknown): void { | |
| 171 | + const existing = args[key]; | |
| 172 | + if (existing === undefined) { | |
| 173 | + args[key] = value; | |
| 174 | + return; | |
| 175 | + } | |
| 176 | + | |
| 177 | + if (Array.isArray(existing)) { | |
| 178 | + existing.push(value); | |
| 179 | + return; | |
| 180 | + } | |
| 181 | + | |
| 182 | + args[key] = [existing, value]; | |
| 183 | +} | |
| 184 | + | |
| 185 | +async function parsePostPayload(request: Request): Promise<OpenApiPayload> { | |
| 186 | + const rawBody = await request.text(); | |
| 187 | + if (rawBody.trim().length === 0) { | |
| 188 | + return {}; | |
| 189 | + } | |
| 190 | + | |
| 191 | + const parsed = JSON.parse(rawBody) as unknown; | |
| 192 | + if (!isRecord(parsed)) { | |
| 193 | + throw new Error("Request body must be a JSON object"); | |
| 194 | + } | |
| 195 | + | |
| 196 | + return parsed as OpenApiPayload; | |
| 197 | +} | |
| 198 | + | |
| 199 | +function parseGetPayload(url: URL): OpenApiPayload { | |
| 200 | + const format = url.searchParams.get("format") ?? undefined; | |
| 201 | + const path = url.searchParams.get("path") ?? undefined; | |
| 202 | + const type = url.searchParams.get("type") ?? undefined; | |
| 203 | + const inlineArgs: Record<string, unknown> = {}; | |
| 204 | + | |
| 205 | + for (const [key, rawValue] of url.searchParams.entries()) { | |
| 206 | + if (CONTROL_QUERY_PARAMS.has(key)) { | |
| 207 | + continue; | |
| 208 | + } | |
| 209 | + mergeQueryParam(inlineArgs, key, parseQueryValue(rawValue)); | |
| 210 | + } | |
| 211 | + | |
| 212 | + const argsParam = url.searchParams.get("args"); | |
| 213 | + let args: Record<string, unknown> | undefined; | |
| 214 | + if (argsParam) { | |
| 215 | + const parsedArgs = JSON.parse(argsParam) as unknown; | |
| 216 | + if (!isRecord(parsedArgs)) { | |
| 217 | + throw new Error("args query parameter must be a JSON object"); | |
| 218 | + } | |
| 219 | + args = { | |
| 220 | + ...parsedArgs, | |
| 221 | + ...inlineArgs, | |
| 222 | + }; | |
| 223 | + } else if (Object.keys(inlineArgs).length > 0) { | |
| 224 | + args = inlineArgs; | |
| 225 | + } | |
| 226 | + | |
| 227 | + return { | |
| 228 | + ...(type ? { type } : {}), | |
| 229 | + ...(format ? { format } : {}), | |
| 230 | + ...(path ? { path } : {}), | |
| 231 | + ...(args ? { args } : {}), | |
| 232 | + }; | |
| 233 | +} | |
| 234 | + | |
| 235 | +function extractArgs(payload: OpenApiPayload): Record<string, unknown> { | |
| 236 | + if (payload.args !== undefined) { | |
| 237 | + if (!isRecord(payload.args)) { | |
| 238 | + throw new Error("args must be an object"); | |
| 239 | + } | |
| 240 | + return payload.args; | |
| 241 | + } | |
| 242 | + | |
| 243 | + const directArgs: Record<string, unknown> = {}; | |
| 244 | + for (const [key, value] of Object.entries(payload)) { | |
| 245 | + if (CONTROL_QUERY_PARAMS.has(key)) { | |
| 246 | + continue; | |
| 247 | + } | |
| 248 | + directArgs[key] = value; | |
| 249 | + } | |
| 250 | + return directArgs; | |
| 251 | +} | |
| 252 | + | |
| 253 | +async function runAsType( | |
| 254 | + ctx: HttpActionCtx, | |
| 255 | + type: FunctionType, | |
| 256 | + functionIdentifier: string, | |
| 257 | + args: Record<string, unknown>, | |
| 258 | +): Promise<unknown> { | |
| 259 | + switch (type) { | |
| 260 | + case "query": | |
| 261 | + return await ctx.runQuery(makeFunctionReference<"query">(functionIdentifier), args); | |
| 262 | + case "mutation": | |
| 263 | + return await ctx.runMutation(makeFunctionReference<"mutation">(functionIdentifier), args); | |
| 264 | + case "action": | |
| 265 | + return await ctx.runAction(makeFunctionReference<"action">(functionIdentifier), args); | |
| 266 | + } | |
| 267 | +} | |
| 268 | + | |
| 269 | +export const openApiHandler = httpAction(async (ctx, request) => { | |
| 270 | + try { | |
| 271 | + const url = new URL(request.url); | |
| 272 | + | |
| 273 | + if (request.method === "GET" && url.pathname === OPENAPI_SPEC_PATH) { | |
| 274 | + const functions = await collectPublicFunctionSpecs(); | |
| 275 | + return Response.json(buildOpenApiDocument(functions, url.origin), { | |
| 276 | + status: 200, | |
| 277 | + headers: { | |
| 278 | + "content-type": "application/json; charset=utf-8", | |
| 279 | + }, | |
| 280 | + }); | |
| 281 | + } | |
| 282 | + | |
| 283 | + const urlIdentifier = functionIdentifierFromUrlPath(url.pathname); | |
| 284 | + | |
| 285 | + const payload = request.method === "GET" | |
| 286 | + ? parseGetPayload(url) | |
| 287 | + : await parsePostPayload(request); | |
| 288 | + | |
| 289 | + const payloadIdentifier = payload.path ?? payload.function; | |
| 290 | + const functionIdentifier = urlIdentifier | |
| 291 | + ?? (typeof payloadIdentifier === "string" ? parseRunPathToFunctionIdentifier(payloadIdentifier) : undefined); | |
| 292 | + | |
| 293 | + if (!functionIdentifier) { | |
| 294 | + return errorResponse("Function path is required. Provide /api/run/<function> or a body path field."); | |
| 295 | + } | |
| 296 | + | |
| 297 | + const format = payload.format ?? JSON_FORMAT; | |
| 298 | + if (format !== JSON_FORMAT) { | |
| 299 | + return errorResponse(`Only format=\"${JSON_FORMAT}\" is supported`); | |
| 300 | + } | |
| 301 | + | |
| 302 | + const args = extractArgs(payload); | |
| 303 | + | |
| 304 | + if (payload.type && !isFunctionType(payload.type)) { | |
| 305 | + return errorResponse("type must be one of: query, mutation, action"); | |
| 306 | + } | |
| 307 | + | |
| 308 | + if (payload.type && isFunctionType(payload.type)) { | |
| 309 | + try { | |
| 310 | + const value = await runAsType(ctx, payload.type, functionIdentifier, args); | |
| 311 | + return successResponse(value); | |
| 312 | + } catch (error) { | |
| 313 | + return functionErrorResponse(error, isTypeLookupError(error, payload.type) ? 404 : 500); | |
| 314 | + } | |
| 315 | + } | |
| 316 | + | |
| 317 | + const attemptedTypes: FunctionType[] = request.method === "GET" | |
| 318 | + ? ["query"] | |
| 319 | + : ["query", "mutation", "action"]; | |
| 320 | + | |
| 321 | + let lookupError: unknown; | |
| 322 | + for (const type of attemptedTypes) { | |
| 323 | + try { | |
| 324 | + const value = await runAsType(ctx, type, functionIdentifier, args); | |
| 325 | + return successResponse(value); | |
| 326 | + } catch (error) { | |
| 327 | + if (isTypeLookupError(error, type)) { | |
| 328 | + lookupError = error; | |
| 329 | + continue; | |
| 330 | + } | |
| 331 | + return functionErrorResponse(error, 500); | |
| 332 | + } | |
| 333 | + } | |
| 334 | + | |
| 335 | + return functionErrorResponse(lookupError ?? new Error(`Function not found: ${functionIdentifier}`), 404); | |
| 336 | + } catch (error) { | |
| 337 | + return errorResponse(error instanceof Error ? error.message : "Invalid OpenAPI request payload"); | |
| 338 | + } | |
| 339 | +}); | |
| @@ -0,0 +1,97 @@ | ||
| 1 | +import { describe, expect, test } from "bun:test"; | |
| 2 | +import { buildOpenApiDocument, schemaFromValidator, type OpenApiFunctionSpec } from "./openapi_spec"; | |
| 3 | +import { collectPublicFunctionSpecs } from "./openapi_spec_registry"; | |
| 4 | + | |
| 5 | +describe("openapi_spec", () => { | |
| 6 | + test("builds object schemas with required fields", () => { | |
| 7 | + const schema = schemaFromValidator({ | |
| 8 | + type: "object", | |
| 9 | + value: { | |
| 10 | + workspaceId: { | |
| 11 | + fieldType: { type: "id", tableName: "workspaces" }, | |
| 12 | + optional: false, | |
| 13 | + }, | |
| 14 | + sessionId: { | |
| 15 | + fieldType: { type: "string" }, | |
| 16 | + optional: true, | |
| 17 | + }, | |
| 18 | + }, | |
| 19 | + }); | |
| 20 | + | |
| 21 | + expect(schema).toEqual({ | |
| 22 | + type: "object", | |
| 23 | + required: ["workspaceId"], | |
| 24 | + properties: { | |
| 25 | + workspaceId: { | |
| 26 | + type: "string", | |
| 27 | + description: 'ID from table "workspaces"', | |
| 28 | + }, | |
| 29 | + sessionId: { | |
| 30 | + type: "string", | |
| 31 | + }, | |
| 32 | + }, | |
| 33 | + }); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + test("parses stringified validator json", () => { | |
| 37 | + const schema = schemaFromValidator( | |
| 38 | + '{"type":"object","value":{"name":{"fieldType":{"type":"string"},"optional":false}}}', | |
| 39 | + ); | |
| 40 | + | |
| 41 | + expect(schema).toEqual({ | |
| 42 | + type: "object", | |
| 43 | + required: ["name"], | |
| 44 | + properties: { | |
| 45 | + name: { | |
| 46 | + type: "string", | |
| 47 | + }, | |
| 48 | + }, | |
| 49 | + }); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + test("generates GET and POST operations from function specs", () => { | |
| 53 | + const functions: OpenApiFunctionSpec[] = [ | |
| 54 | + { | |
| 55 | + identifier: "app:getClientConfig", | |
| 56 | + functionType: "query", | |
| 57 | + method: "GET", | |
| 58 | + args: { type: "object", value: {} }, | |
| 59 | + returns: { type: "object", value: {} }, | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + identifier: "workspace:upsertCredential", | |
| 63 | + functionType: "mutation", | |
| 64 | + method: "POST", | |
| 65 | + args: { type: "object", value: {} }, | |
| 66 | + returns: { type: "any" }, | |
| 67 | + }, | |
| 68 | + ]; | |
| 69 | + | |
| 70 | + const document = buildOpenApiDocument(functions, "https://example.convex.cloud"); | |
| 71 | + const paths = document.paths as Record<string, Record<string, unknown>>; | |
| 72 | + | |
| 73 | + expect(paths["/api/run/app/getClientConfig"]?.get).toBeDefined(); | |
| 74 | + expect(paths["/api/run/workspace/upsertCredential"]?.post).toBeDefined(); | |
| 75 | + }); | |
| 76 | + | |
| 77 | + test("collects public function specs from convex modules", async () => { | |
| 78 | + const specs = await collectPublicFunctionSpecs(); | |
| 79 | + const identifiers = new Set(specs.map((spec) => spec.identifier)); | |
| 80 | + | |
| 81 | + expect(identifiers.has("app:getClientConfig")).toBe(true); | |
| 82 | + expect(identifiers.has("workspace:listTasks")).toBe(true); | |
| 83 | + expect(identifiers.has("executor:createTask")).toBe(true); | |
| 84 | + | |
| 85 | + const updateRole = specs.find((spec) => spec.identifier === "organizationMembers:updateRole"); | |
| 86 | + expect(typeof updateRole?.args).toBe("string"); | |
| 87 | + | |
| 88 | + const updateRoleDoc = buildOpenApiDocument([updateRole!], "https://example.convex.cloud"); | |
| 89 | + const schemas = (updateRoleDoc.components as { schemas: Record<string, unknown> }).schemas; | |
| 90 | + const argsSchema = schemas.Request_organizationMembers_updateRole as { | |
| 91 | + properties?: Record<string, unknown>; | |
| 92 | + }; | |
| 93 | + | |
| 94 | + expect(argsSchema.properties?.accountId).toBeDefined(); | |
| 95 | + expect(argsSchema.properties?.role).toBeDefined(); | |
| 96 | + }); | |
| 97 | +}); | |
| @@ -0,0 +1,282 @@ | ||
| 1 | +type FunctionType = "query" | "mutation" | "action"; | |
| 2 | + | |
| 3 | +const CONTROL_QUERY_PARAMS = new Set(["format", "args", "path", "function", "type"]); | |
| 4 | + | |
| 5 | +export type ValidatorJson = { | |
| 6 | + type: string; | |
| 7 | + [key: string]: unknown; | |
| 8 | +}; | |
| 9 | + | |
| 10 | +export type OpenApiFunctionSpec = { | |
| 11 | + identifier: string; | |
| 12 | + functionType: FunctionType; | |
| 13 | + method: "GET" | "POST"; | |
| 14 | + args?: ValidatorJson | string | null; | |
| 15 | + returns?: ValidatorJson | string | null; | |
| 16 | +}; | |
| 17 | + | |
| 18 | +function toRecord(value: unknown): Record<string, unknown> { | |
| 19 | + return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 20 | + ? (value as Record<string, unknown>) | |
| 21 | + : {}; | |
| 22 | +} | |
| 23 | + | |
| 24 | +function schemaId(identifier: string): string { | |
| 25 | + return identifier.replace(/[^a-zA-Z0-9_]/g, "_"); | |
| 26 | +} | |
| 27 | + | |
| 28 | +function endpointPath(identifier: string): string { | |
| 29 | + const [modulePath, exportName] = identifier.split(":"); | |
| 30 | + if (!modulePath || !exportName) { | |
| 31 | + return `/api/run/${identifier.replace(/:/g, "/")}`; | |
| 32 | + } | |
| 33 | + return `/api/run/${modulePath}/${exportName}`; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function buildGetParametersFromArgsSchema(argsSchema: Record<string, unknown>): Array<Record<string, unknown>> { | |
| 37 | + const schemaType = typeof argsSchema.type === "string" ? argsSchema.type : undefined; | |
| 38 | + const properties = toRecord(argsSchema.properties); | |
| 39 | + const required = Array.isArray(argsSchema.required) | |
| 40 | + ? new Set(argsSchema.required.filter((entry): entry is string => typeof entry === "string")) | |
| 41 | + : new Set<string>(); | |
| 42 | + | |
| 43 | + if (schemaType !== "object" || Object.keys(properties).length === 0) { | |
| 44 | + return [{ | |
| 45 | + in: "query", | |
| 46 | + name: "args", | |
| 47 | + required: false, | |
| 48 | + schema: { | |
| 49 | + type: "string", | |
| 50 | + description: "JSON-encoded args object", | |
| 51 | + }, | |
| 52 | + }]; | |
| 53 | + } | |
| 54 | + | |
| 55 | + const parameters: Array<Record<string, unknown>> = []; | |
| 56 | + for (const [name, value] of Object.entries(properties)) { | |
| 57 | + if (CONTROL_QUERY_PARAMS.has(name)) { | |
| 58 | + continue; | |
| 59 | + } | |
| 60 | + | |
| 61 | + parameters.push({ | |
| 62 | + in: "query", | |
| 63 | + name, | |
| 64 | + required: required.has(name), | |
| 65 | + schema: toRecord(value), | |
| 66 | + }); | |
| 67 | + } | |
| 68 | + | |
| 69 | + return parameters; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export function schemaFromValidator(validator?: ValidatorJson | string | null): Record<string, unknown> { | |
| 73 | + if (typeof validator === "string") { | |
| 74 | + try { | |
| 75 | + const parsed = JSON.parse(validator) as ValidatorJson; | |
| 76 | + return schemaFromValidator(parsed); | |
| 77 | + } catch { | |
| 78 | + return {}; | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + if (!validator || typeof validator !== "object") { | |
| 83 | + return {}; | |
| 84 | + } | |
| 85 | + | |
| 86 | + switch (validator.type) { | |
| 87 | + case "null": | |
| 88 | + return { type: "string", nullable: true }; | |
| 89 | + case "number": | |
| 90 | + return { type: "number" }; | |
| 91 | + case "bigint": | |
| 92 | + return { type: "integer", format: "int64" }; | |
| 93 | + case "boolean": | |
| 94 | + return { type: "boolean" }; | |
| 95 | + case "string": | |
| 96 | + return { type: "string" }; | |
| 97 | + case "bytes": | |
| 98 | + return { type: "string", format: "byte" }; | |
| 99 | + case "any": | |
| 100 | + return {}; | |
| 101 | + case "literal": { | |
| 102 | + const literalValue = validator.value; | |
| 103 | + if (typeof literalValue === "string") { | |
| 104 | + return { type: "string", enum: [literalValue] }; | |
| 105 | + } | |
| 106 | + if (typeof literalValue === "boolean") { | |
| 107 | + return { type: "boolean", enum: [literalValue] }; | |
| 108 | + } | |
| 109 | + return { type: "number", enum: [literalValue] }; | |
| 110 | + } | |
| 111 | + case "id": | |
| 112 | + return { | |
| 113 | + type: "string", | |
| 114 | + description: `ID from table "${String(validator.tableName ?? "unknown")}"`, | |
| 115 | + }; | |
| 116 | + case "array": | |
| 117 | + return { | |
| 118 | + type: "array", | |
| 119 | + items: schemaFromValidator((validator.value as ValidatorJson | undefined) ?? { type: "any" }), | |
| 120 | + }; | |
| 121 | + case "record": { | |
| 122 | + const values = validator.values as { fieldType?: ValidatorJson } | undefined; | |
| 123 | + return { | |
| 124 | + type: "object", | |
| 125 | + additionalProperties: schemaFromValidator(values?.fieldType ?? { type: "any" }), | |
| 126 | + }; | |
| 127 | + } | |
| 128 | + case "object": { | |
| 129 | + const rawMembers = validator.value as Record<string, { fieldType: ValidatorJson; optional?: boolean }> | undefined; | |
| 130 | + const members = rawMembers ?? {}; | |
| 131 | + const properties: Record<string, unknown> = {}; | |
| 132 | + const required: string[] = []; | |
| 133 | + | |
| 134 | + for (const [key, value] of Object.entries(members)) { | |
| 135 | + properties[key] = schemaFromValidator(value.fieldType); | |
| 136 | + if (!value.optional) { | |
| 137 | + required.push(key); | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + return { | |
| 142 | + type: "object", | |
| 143 | + ...(required.length > 0 ? { required } : {}), | |
| 144 | + ...(Object.keys(properties).length > 0 ? { properties } : {}), | |
| 145 | + }; | |
| 146 | + } | |
| 147 | + case "union": { | |
| 148 | + const members = Array.isArray(validator.value) ? (validator.value as ValidatorJson[]) : []; | |
| 149 | + const hasNull = members.some((member) => member.type === "null"); | |
| 150 | + const nonNullMembers = members.filter((member) => member.type !== "null"); | |
| 151 | + | |
| 152 | + if (nonNullMembers.length === 1) { | |
| 153 | + return { | |
| 154 | + ...schemaFromValidator(nonNullMembers[0]), | |
| 155 | + ...(hasNull ? { nullable: true } : {}), | |
| 156 | + }; | |
| 157 | + } | |
| 158 | + | |
| 159 | + return { | |
| 160 | + ...(hasNull ? { nullable: true } : {}), | |
| 161 | + oneOf: nonNullMembers.map((member) => schemaFromValidator(member)), | |
| 162 | + }; | |
| 163 | + } | |
| 164 | + default: | |
| 165 | + return {}; | |
| 166 | + } | |
| 167 | +} | |
| 168 | + | |
| 169 | +export function buildOpenApiDocument(functions: OpenApiFunctionSpec[], serverUrl: string): Record<string, unknown> { | |
| 170 | + const paths: Record<string, Record<string, unknown>> = {}; | |
| 171 | + const schemas: Record<string, unknown> = { | |
| 172 | + FailedResponse: { | |
| 173 | + type: "object", | |
| 174 | + properties: { | |
| 175 | + errorMessage: { type: "string" }, | |
| 176 | + errorData: {}, | |
| 177 | + }, | |
| 178 | + }, | |
| 179 | + }; | |
| 180 | + | |
| 181 | + for (const fn of functions) { | |
| 182 | + const id = schemaId(fn.identifier); | |
| 183 | + const requestSchemaName = `Request_${id}`; | |
| 184 | + const responseSchemaName = `Response_${id}`; | |
| 185 | + | |
| 186 | + const argsSchema = schemaFromValidator(fn.args); | |
| 187 | + schemas[requestSchemaName] = argsSchema; | |
| 188 | + schemas[responseSchemaName] = schemaFromValidator(fn.returns); | |
| 189 | + | |
| 190 | + const path = endpointPath(fn.identifier); | |
| 191 | + const method = fn.method.toLowerCase(); | |
| 192 | + | |
| 193 | + const baseOperation: Record<string, unknown> = { | |
| 194 | + summary: `Calls ${fn.functionType} ${fn.identifier}`, | |
| 195 | + tags: [fn.functionType], | |
| 196 | + operationId: `${fn.functionType}_${id}`, | |
| 197 | + responses: { | |
| 198 | + "200": { | |
| 199 | + description: "Convex executed the request", | |
| 200 | + content: { | |
| 201 | + "application/json": { | |
| 202 | + schema: { $ref: `#/components/schemas/${responseSchemaName}` }, | |
| 203 | + }, | |
| 204 | + }, | |
| 205 | + }, | |
| 206 | + "400": { | |
| 207 | + description: "Invalid request payload", | |
| 208 | + content: { | |
| 209 | + "application/json": { | |
| 210 | + schema: { $ref: "#/components/schemas/FailedResponse" }, | |
| 211 | + }, | |
| 212 | + }, | |
| 213 | + }, | |
| 214 | + "404": { | |
| 215 | + description: "Function not found", | |
| 216 | + content: { | |
| 217 | + "application/json": { | |
| 218 | + schema: { $ref: "#/components/schemas/FailedResponse" }, | |
| 219 | + }, | |
| 220 | + }, | |
| 221 | + }, | |
| 222 | + "500": { | |
| 223 | + description: "Function execution failed", | |
| 224 | + content: { | |
| 225 | + "application/json": { | |
| 226 | + schema: { $ref: "#/components/schemas/FailedResponse" }, | |
| 227 | + }, | |
| 228 | + }, | |
| 229 | + }, | |
| 230 | + }, | |
| 231 | + }; | |
| 232 | + | |
| 233 | + const operation = fn.method === "POST" | |
| 234 | + ? { | |
| 235 | + ...baseOperation, | |
| 236 | + requestBody: { | |
| 237 | + required: true, | |
| 238 | + content: { | |
| 239 | + "application/json": { | |
| 240 | + schema: { $ref: `#/components/schemas/${requestSchemaName}` }, | |
| 241 | + }, | |
| 242 | + }, | |
| 243 | + }, | |
| 244 | + } | |
| 245 | + : { | |
| 246 | + ...baseOperation, | |
| 247 | + parameters: buildGetParametersFromArgsSchema(argsSchema), | |
| 248 | + }; | |
| 249 | + | |
| 250 | + const existingPath = paths[path] ?? {}; | |
| 251 | + paths[path] = { | |
| 252 | + ...existingPath, | |
| 253 | + [method]: operation, | |
| 254 | + }; | |
| 255 | + } | |
| 256 | + | |
| 257 | + return { | |
| 258 | + openapi: "3.0.3", | |
| 259 | + info: { | |
| 260 | + title: "Convex API", | |
| 261 | + version: "0.0.0", | |
| 262 | + }, | |
| 263 | + servers: [{ url: serverUrl }], | |
| 264 | + security: [{ bearerAuth: [] }], | |
| 265 | + tags: [ | |
| 266 | + { name: "query", description: "Functions that read data" }, | |
| 267 | + { name: "mutation", description: "Functions that write data" }, | |
| 268 | + { name: "action", description: "Functions that can call external APIs" }, | |
| 269 | + ], | |
| 270 | + paths, | |
| 271 | + components: { | |
| 272 | + securitySchemes: { | |
| 273 | + bearerAuth: { | |
| 274 | + type: "http", | |
| 275 | + scheme: "bearer", | |
| 276 | + description: "Use Authorization: Bearer <token>", | |
| 277 | + }, | |
| 278 | + }, | |
| 279 | + schemas, | |
| 280 | + }, | |
| 281 | + }; | |
| 282 | +} | |
| @@ -0,0 +1,103 @@ | ||
| 1 | +import type { OpenApiFunctionSpec, ValidatorJson } from "./openapi_spec"; | |
| 2 | + | |
| 3 | +type ConvexPublicRegistration = { | |
| 4 | + isPublic?: boolean; | |
| 5 | + isQuery?: boolean; | |
| 6 | + isMutation?: boolean; | |
| 7 | + isAction?: boolean; | |
| 8 | + exportArgs?: () => ValidatorJson | null; | |
| 9 | + exportReturns?: () => ValidatorJson | null; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +function readValidator( | |
| 13 | + fn: ConvexPublicRegistration, | |
| 14 | + key: "exportArgs" | "exportReturns", | |
| 15 | +): ValidatorJson | string | null { | |
| 16 | + try { | |
| 17 | + return fn[key]?.() ?? null; | |
| 18 | + } catch { | |
| 19 | + return null; | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +function collectFromModule(modulePath: string, exportsObject: Record<string, unknown>): OpenApiFunctionSpec[] { | |
| 24 | + const functions: OpenApiFunctionSpec[] = []; | |
| 25 | + | |
| 26 | + for (const [exportName, value] of Object.entries(exportsObject)) { | |
| 27 | + if (typeof value !== "function") { | |
| 28 | + continue; | |
| 29 | + } | |
| 30 | + | |
| 31 | + const fn = value as ConvexPublicRegistration; | |
| 32 | + if (!fn.isPublic) { | |
| 33 | + continue; | |
| 34 | + } | |
| 35 | + | |
| 36 | + const functionType = fn.isQuery ? "query" : fn.isMutation ? "mutation" : fn.isAction ? "action" : null; | |
| 37 | + if (!functionType) { | |
| 38 | + continue; | |
| 39 | + } | |
| 40 | + | |
| 41 | + functions.push({ | |
| 42 | + identifier: `${modulePath}:${exportName}`, | |
| 43 | + functionType, | |
| 44 | + method: functionType === "query" ? "GET" : "POST", | |
| 45 | + args: readValidator(fn, "exportArgs"), | |
| 46 | + returns: readValidator(fn, "exportReturns"), | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + | |
| 50 | + return functions; | |
| 51 | +} | |
| 52 | + | |
| 53 | +const moduleLoaders: Array<{ | |
| 54 | + modulePath: string; | |
| 55 | + load: () => Promise<Record<string, unknown>>; | |
| 56 | +}> = [ | |
| 57 | + { modulePath: "accounts", load: () => import("../accounts") }, | |
| 58 | + { modulePath: "app", load: () => import("../app") }, | |
| 59 | + { modulePath: "auth", load: () => import("../auth") }, | |
| 60 | + { modulePath: "billing", load: () => import("../billing") }, | |
| 61 | + { modulePath: "executor", load: () => import("../executor") }, | |
| 62 | + { modulePath: "invites", load: () => import("../invites") }, | |
| 63 | + { modulePath: "organizationMembers", load: () => import("../organizationMembers") }, | |
| 64 | + { modulePath: "organizations", load: () => import("../organizations") }, | |
| 65 | + { modulePath: "runtimeCallbacks", load: () => import("../runtimeCallbacks") }, | |
| 66 | + { modulePath: "workspace", load: () => import("../workspace") }, | |
| 67 | + { modulePath: "workspaces", load: () => import("../workspaces") }, | |
| 68 | +]; | |
| 69 | + | |
| 70 | +export async function collectPublicFunctionSpecs(): Promise<OpenApiFunctionSpec[]> { | |
| 71 | + const moduleFunctions: OpenApiFunctionSpec[] = []; | |
| 72 | + | |
| 73 | + for (const loader of moduleLoaders) { | |
| 74 | + try { | |
| 75 | + const exportsObject = await loader.load(); | |
| 76 | + moduleFunctions.push(...collectFromModule(loader.modulePath, exportsObject)); | |
| 77 | + } catch { | |
| 78 | + // Skip modules that are unavailable in the current runtime. | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + // Node runtime public actions are declared in "use node" files and are added manually. | |
| 83 | + const nodeRuntimeFunctions: OpenApiFunctionSpec[] = [ | |
| 84 | + { | |
| 85 | + identifier: "credentialsNode:upsertCredential", | |
| 86 | + functionType: "action", | |
| 87 | + method: "POST", | |
| 88 | + args: { type: "any" }, | |
| 89 | + returns: { type: "any" }, | |
| 90 | + }, | |
| 91 | + { | |
| 92 | + identifier: "executorNode:listToolsWithWarnings", | |
| 93 | + functionType: "action", | |
| 94 | + method: "POST", | |
| 95 | + args: { type: "any" }, | |
| 96 | + returns: { type: "any" }, | |
| 97 | + }, | |
| 98 | + ]; | |
| 99 | + | |
| 100 | + const merged = [...moduleFunctions, ...nodeRuntimeFunctions]; | |
| 101 | + merged.sort((a, b) => a.identifier.localeCompare(b.identifier)); | |
| 102 | + return merged; | |
| 103 | +} | |
| @@ -23,6 +23,7 @@ const organizationRoleValidator = v.union( · 22 unmodified lines | ||
| 23 | 23 | ); |
| 24 | 24 | |
| 25 | 25 | export const list = organizationQuery({ |
| 26 | + method: "GET", | |
| 26 | 27 | requireAdmin: true, |
| 27 | 28 | args: {}, |
| 28 | 29 | handler: async (ctx) => { |
| @@ -47,6 +48,7 @@ export const list = organizationQuery({ · 18 unmodified lines | ||
| 47 | 48 | }); |
| 48 | 49 | |
| 49 | 50 | export const create = organizationMutation({ |
| 51 | + method: "POST", | |
| 50 | 52 | requireAdmin: true, |
| 51 | 53 | args: { |
| 52 | 54 | email: v.string(), |
| @@ -197,6 +199,7 @@ export const deliverWorkosInvite = internalAction({ · 144 unmodified lines | ||
| 197 | 199 | }); |
| 198 | 200 | |
| 199 | 201 | export const revoke = organizationMutation({ |
| 202 | + method: "POST", | |
| 200 | 203 | requireAdmin: true, |
| 201 | 204 | args: { |
| 202 | 205 | inviteId: v.id("invites"), |
| @@ -13,6 +13,7 @@ const organizationRoleValidator = v.union( · 12 unmodified lines | ||
| 13 | 13 | ); |
| 14 | 14 | |
| 15 | 15 | export const list = organizationQuery({ |
| 16 | + method: "GET", | |
| 16 | 17 | args: {}, |
| 17 | 18 | handler: async (ctx) => { |
| 18 | 19 | const members = await ctx.db |
| @@ -43,6 +44,7 @@ export const list = organizationQuery({ · 24 unmodified lines | ||
| 43 | 44 | }); |
| 44 | 45 | |
| 45 | 46 | export const updateRole = organizationMutation({ |
| 47 | + method: "POST", | |
| 46 | 48 | args: { |
| 47 | 49 | accountId: v.id("accounts"), |
| 48 | 50 | role: organizationRoleValidator, |
| @@ -70,6 +72,7 @@ export const updateRole = organizationMutation({ · 21 unmodified lines | ||
| 70 | 72 | }); |
| 71 | 73 | |
| 72 | 74 | export const updateBillable = organizationMutation({ |
| 75 | + method: "POST", | |
| 73 | 76 | args: { |
| 74 | 77 | accountId: v.id("accounts"), |
| 75 | 78 | billable: v.boolean(), |
| @@ -99,6 +102,7 @@ export const updateBillable = organizationMutation({ · 23 unmodified lines | ||
| 99 | 102 | }); |
| 100 | 103 | |
| 101 | 104 | export const remove = organizationMutation({ |
| 105 | + method: "POST", | |
| 102 | 106 | args: { |
| 103 | 107 | accountId: v.id("accounts"), |
| 104 | 108 | }, |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | } from "../src/organizations/handlers"; |
| 10 | 10 | |
| 11 | 11 | export const create = authedMutation({ |
| 12 | + method: "POST", | |
| 12 | 13 | args: { |
| 13 | 14 | name: v.string(), |
| 14 | 15 | }, |
| @@ -18,6 +19,7 @@ export const create = authedMutation({ · 3 unmodified lines | ||
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | 21 | export const listMine = optionalAccountQuery({ |
| 22 | + method: "GET", | |
| 21 | 23 | args: {}, |
| 22 | 24 | handler: async (ctx) => { |
| 23 | 25 | return await listOrganizationsMineHandler(ctx); |
| @@ -25,6 +27,7 @@ export const listMine = optionalAccountQuery({ · 1 unmodified line | ||
| 25 | 27 | }); |
| 26 | 28 | |
| 27 | 29 | export const getNavigationState = optionalAccountQuery({ |
| 30 | + method: "GET", | |
| 28 | 31 | args: {}, |
| 29 | 32 | handler: async (ctx) => { |
| 30 | 33 | return await getNavigationStateHandler(ctx); |
| @@ -32,6 +35,7 @@ export const getNavigationState = optionalAccountQuery({ · 1 unmodified line | ||
| 32 | 35 | }); |
| 33 | 36 | |
| 34 | 37 | export const getOrganizationAccess = optionalAccountQuery({ |
| 38 | + method: "GET", | |
| 35 | 39 | args: { |
| 36 | 40 | organizationId: v.id("organizations"), |
| 37 | 41 | }, |
| @@ -41,6 +45,7 @@ export const getOrganizationAccess = optionalAccountQuery({ · 3 unmodified lines | ||
| 41 | 45 | }); |
| 42 | 46 | |
| 43 | 47 | export const resolveWorkosOrganizationId = optionalAccountQuery({ |
| 48 | + method: "GET", | |
| 44 | 49 | args: { |
| 45 | 50 | organizationId: v.id("organizations"), |
| 46 | 51 | }, |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { v } from "convex/values"; |
| 2 | 2 | import { internal } from "./_generated/api"; |
| 3 | −import { action, mutation, query } from "./_generated/server"; | |
| 3 | +import { customAction, customMutation, customQuery } from "../../core/src/function-builders"; | |
| 4 | 4 | import { |
| 5 | 5 | completeRunHandler, |
| 6 | 6 | getApprovalStatusHandler, |
| @@ -8,7 +8,8 @@ import { · 1 unmodified line | ||
| 8 | 8 | } from "../src/runtime-callbacks/handlers"; |
| 9 | 9 | import { jsonObjectValidator } from "../src/database/validators"; |
| 10 | 10 | |
| 11 | −export const handleToolCall = action({ | |
| 11 | +export const handleToolCall = customAction({ | |
| 12 | + method: "POST", | |
| 12 | 13 | args: { |
| 13 | 14 | internalSecret: v.string(), |
| 14 | 15 | runId: v.string(), |
| @@ -21,7 +22,8 @@ export const handleToolCall = action({ · 6 unmodified lines | ||
| 21 | 22 | }, |
| 22 | 23 | }); |
| 23 | 24 | |
| 24 | −export const completeRun = mutation({ | |
| 25 | +export const completeRun = customMutation({ | |
| 26 | + method: "POST", | |
| 25 | 27 | args: { |
| 26 | 28 | internalSecret: v.string(), |
| 27 | 29 | runId: v.string(), |
| @@ -35,7 +37,8 @@ export const completeRun = mutation({ · 7 unmodified lines | ||
| 35 | 37 | }, |
| 36 | 38 | }); |
| 37 | 39 | |
| 38 | −export const getApprovalStatus = query({ | |
| 40 | +export const getApprovalStatus = customQuery({ | |
| 41 | + method: "GET", | |
| 39 | 42 | args: { |
| 40 | 43 | internalSecret: v.string(), |
| 41 | 44 | runId: v.string(), |
| @@ -451,9 +451,11 @@ export default defineSchema({ · 450 unmodified lines | ||
| 451 | 451 | // Stores the currently "ready" build id for search + invocation. |
| 452 | 452 | workspaceToolRegistryState: defineTable({ |
| 453 | 453 | workspaceId: v.id("workspaces"), |
| 454 | − signature: v.string(), | |
| 454 | + signature: v.optional(v.string()), | |
| 455 | 455 | readyBuildId: v.optional(v.string()), |
| 456 | 456 | buildingBuildId: v.optional(v.string()), |
| 457 | + typesStorageId: v.optional(v.id("_storage")), | |
| 458 | + warnings: v.optional(v.array(v.string())), | |
| 457 | 459 | createdAt: v.number(), |
| 458 | 460 | updatedAt: v.number(), |
| 459 | 461 | }) |
| @@ -18,6 +18,8 @@ export const getState = internalQuery({ · 17 unmodified lines | ||
| 18 | 18 | signature: entry.signature, |
| 19 | 19 | readyBuildId: entry.readyBuildId, |
| 20 | 20 | buildingBuildId: entry.buildingBuildId, |
| 21 | + typesStorageId: entry.typesStorageId, | |
| 22 | + warnings: entry.warnings ?? [], | |
| 21 | 23 | updatedAt: entry.updatedAt, |
| 22 | 24 | }; |
| 23 | 25 | }, |
| @@ -38,7 +40,6 @@ export const beginBuild = internalMutation({ · 14 unmodified lines | ||
| 38 | 40 | |
| 39 | 41 | if (existing) { |
| 40 | 42 | await ctx.db.patch(existing._id, { |
| 41 | − signature: args.signature, | |
| 42 | 43 | buildingBuildId: args.buildId, |
| 43 | 44 | updatedAt: now, |
| 44 | 45 | }); |
| @@ -50,6 +51,8 @@ export const beginBuild = internalMutation({ · 5 unmodified lines | ||
| 50 | 51 | signature: args.signature, |
| 51 | 52 | readyBuildId: undefined, |
| 52 | 53 | buildingBuildId: args.buildId, |
| 54 | + typesStorageId: undefined, | |
| 55 | + warnings: [], | |
| 53 | 56 | createdAt: now, |
| 54 | 57 | updatedAt: now, |
| 55 | 58 | }); |
| @@ -144,6 +147,7 @@ export const finishBuild = internalMutation({ · 88 unmodified lines | ||
| 144 | 147 | args: { |
| 145 | 148 | workspaceId: v.id("workspaces"), |
| 146 | 149 | buildId: v.string(), |
| 150 | + signature: v.string(), | |
| 147 | 151 | }, |
| 148 | 152 | handler: async (ctx, args) => { |
| 149 | 153 | const now = Date.now(); |
| @@ -155,9 +159,11 @@ export const finishBuild = internalMutation({ · 5 unmodified lines | ||
| 155 | 159 | if (!state) { |
| 156 | 160 | await ctx.db.insert("workspaceToolRegistryState", { |
| 157 | 161 | workspaceId: args.workspaceId, |
| 158 | − signature: "", | |
| 162 | + signature: args.signature, | |
| 159 | 163 | readyBuildId: args.buildId, |
| 160 | 164 | buildingBuildId: undefined, |
| 165 | + typesStorageId: undefined, | |
| 166 | + warnings: [], | |
| 161 | 167 | createdAt: now, |
| 162 | 168 | updatedAt: now, |
| 163 | 169 | }); |
| @@ -171,12 +177,64 @@ export const finishBuild = internalMutation({ · 7 unmodified lines | ||
| 171 | 177 | |
| 172 | 178 | await ctx.db.patch(state._id, { |
| 173 | 179 | readyBuildId: args.buildId, |
| 180 | + signature: args.signature, | |
| 174 | 181 | buildingBuildId: undefined, |
| 175 | 182 | updatedAt: now, |
| 176 | 183 | }); |
| 177 | 184 | }, |
| 178 | 185 | }); |
| 179 | 186 | |
| 187 | +export const updateBuildMetadata = internalMutation({ | |
| 188 | + args: { | |
| 189 | + workspaceId: v.id("workspaces"), | |
| 190 | + buildId: v.string(), | |
| 191 | + typesStorageId: v.optional(v.id("_storage")), | |
| 192 | + warnings: v.array(v.string()), | |
| 193 | + }, | |
| 194 | + handler: async (ctx, args) => { | |
| 195 | + const state = await ctx.db | |
| 196 | + .query("workspaceToolRegistryState") | |
| 197 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 198 | + .unique(); | |
| 199 | + if (!state) { | |
| 200 | + return; | |
| 201 | + } | |
| 202 | + if (state.readyBuildId !== args.buildId) { | |
| 203 | + return; | |
| 204 | + } | |
| 205 | + | |
| 206 | + await ctx.db.patch(state._id, { | |
| 207 | + typesStorageId: args.typesStorageId, | |
| 208 | + warnings: args.warnings, | |
| 209 | + updatedAt: Date.now(), | |
| 210 | + }); | |
| 211 | + }, | |
| 212 | +}); | |
| 213 | + | |
| 214 | +export const failBuild = internalMutation({ | |
| 215 | + args: { | |
| 216 | + workspaceId: v.id("workspaces"), | |
| 217 | + buildId: v.string(), | |
| 218 | + }, | |
| 219 | + handler: async (ctx, args) => { | |
| 220 | + const state = await ctx.db | |
| 221 | + .query("workspaceToolRegistryState") | |
| 222 | + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) | |
| 223 | + .unique(); | |
| 224 | + if (!state) { | |
| 225 | + return; | |
| 226 | + } | |
| 227 | + if (state.buildingBuildId !== args.buildId) { | |
| 228 | + return; | |
| 229 | + } | |
| 230 | + | |
| 231 | + await ctx.db.patch(state._id, { | |
| 232 | + buildingBuildId: undefined, | |
| 233 | + updatedAt: Date.now(), | |
| 234 | + }); | |
| 235 | + }, | |
| 236 | +}); | |
| 237 | + | |
| 180 | 238 | const PRUNE_DELETE_PAGE_SIZE = 10; |
| 181 | 239 | const PRUNE_NAMESPACE_SCAN_PAGE_SIZE = 250; |
| 182 | 240 | |
| @@ -363,6 +421,7 @@ export const getToolByPath = internalQuery({ · 180 unmodified lines | ||
| 363 | 421 | displayOutput: entry.displayOutput, |
| 364 | 422 | requiredInputKeys: entry.requiredInputKeys, |
| 365 | 423 | previewInputKeys: entry.previewInputKeys, |
| 424 | + typedRef: entry.typedRef, | |
| 366 | 425 | serializedToolJson: entry.serializedToolJson, |
| 367 | 426 | }; |
| 368 | 427 | }, |
| @@ -377,7 +436,7 @@ export const listToolsByNamespace = internalQuery({ · 8 unmodified lines | ||
| 377 | 436 | }, |
| 378 | 437 | handler: async (ctx, args) => { |
| 379 | 438 | const namespace = args.namespace.trim().toLowerCase(); |
| 380 | − const limit = Math.max(1, Math.min(200, Math.floor(args.limit))); | |
| 439 | + const limit = Math.max(1, Math.min(20_000, Math.floor(args.limit))); | |
| 381 | 440 | if (!namespace) return []; |
| 382 | 441 | |
| 383 | 442 | const entries = await ctx.db |
| @@ -398,6 +457,7 @@ export const listToolsByNamespace = internalQuery({ · 14 unmodified lines | ||
| 398 | 457 | displayOutput: entry.displayOutput, |
| 399 | 458 | requiredInputKeys: entry.requiredInputKeys, |
| 400 | 459 | previewInputKeys: entry.previewInputKeys, |
| 460 | + typedRef: entry.typedRef, | |
| 401 | 461 | })); |
| 402 | 462 | }, |
| 403 | 463 | }); |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import { v } from "convex/values"; |
| 2 | 2 | import { internal } from "./_generated/api"; |
| 3 | −import { mutation } from "./_generated/server"; | |
| 4 | −import { workspaceMutation, workspaceQuery } from "../../core/src/function-builders"; | |
| 3 | +import { customMutation, workspaceMutation, workspaceQuery } from "../../core/src/function-builders"; | |
| 5 | 4 | import { getOrganizationMembership, isAdminRole } from "../../core/src/identity"; |
| 6 | 5 | import { |
| 7 | 6 | credentialProviderValidator, |
| @@ -15,7 +14,8 @@ import { · 7 unmodified lines | ||
| 15 | 14 | toolSourceTypeValidator, |
| 16 | 15 | } from "../src/database/validators"; |
| 17 | 16 | |
| 18 | −export const bootstrapAnonymousSession = mutation({ | |
| 17 | +export const bootstrapAnonymousSession = customMutation({ | |
| 18 | + method: "POST", | |
| 19 | 19 | args: { |
| 20 | 20 | sessionId: v.optional(v.string()), |
| 21 | 21 | accountId: v.optional(v.string()), |
| @@ -27,6 +27,7 @@ export const bootstrapAnonymousSession = mutation({ · 5 unmodified lines | ||
| 27 | 27 | }); |
| 28 | 28 | |
| 29 | 29 | export const listTasks = workspaceQuery({ |
| 30 | + method: "GET", | |
| 30 | 31 | args: {}, |
| 31 | 32 | handler: async (ctx) => { |
| 32 | 33 | return await ctx.runQuery(internal.database.listTasks, { |
| @@ -36,6 +37,7 @@ export const listTasks = workspaceQuery({ · 3 unmodified lines | ||
| 36 | 37 | }); |
| 37 | 38 | |
| 38 | 39 | export const listPendingApprovals = workspaceQuery({ |
| 40 | + method: "GET", | |
| 39 | 41 | args: {}, |
| 40 | 42 | handler: async (ctx) => { |
| 41 | 43 | return await ctx.runQuery(internal.database.listPendingApprovals, { |
| @@ -45,6 +47,7 @@ export const listPendingApprovals = workspaceQuery({ · 3 unmodified lines | ||
| 45 | 47 | }); |
| 46 | 48 | |
| 47 | 49 | export const upsertAccessPolicy = workspaceMutation({ |
| 50 | + method: "POST", | |
| 48 | 51 | requireAdmin: true, |
| 49 | 52 | args: { |
| 50 | 53 | id: v.optional(v.string()), |
| @@ -73,6 +76,7 @@ export const upsertAccessPolicy = workspaceMutation({ · 22 unmodified lines | ||
| 73 | 76 | }); |
| 74 | 77 | |
| 75 | 78 | export const listAccessPolicies = workspaceQuery({ |
| 79 | + method: "GET", | |
| 76 | 80 | args: {}, |
| 77 | 81 | handler: async (ctx) => { |
| 78 | 82 | return await ctx.runQuery(internal.database.listAccessPolicies, { |
| @@ -83,6 +87,7 @@ export const listAccessPolicies = workspaceQuery({ · 4 unmodified lines | ||
| 83 | 87 | }); |
| 84 | 88 | |
| 85 | 89 | export const upsertCredential = workspaceMutation({ |
| 90 | + method: "POST", | |
| 86 | 91 | requireAdmin: true, |
| 87 | 92 | args: { |
| 88 | 93 | id: v.optional(v.string()), |
| @@ -109,6 +114,7 @@ export const upsertCredential = workspaceMutation({ · 20 unmodified lines | ||
| 109 | 114 | }); |
| 110 | 115 | |
| 111 | 116 | export const listCredentials = workspaceQuery({ |
| 117 | + method: "GET", | |
| 112 | 118 | requireAdmin: true, |
| 113 | 119 | args: {}, |
| 114 | 120 | handler: async (ctx) => { |
| @@ -128,6 +134,7 @@ export const listCredentials = workspaceQuery({ · 13 unmodified lines | ||
| 128 | 134 | }); |
| 129 | 135 | |
| 130 | 136 | export const resolveCredential = workspaceQuery({ |
| 137 | + method: "GET", | |
| 131 | 138 | requireAdmin: true, |
| 132 | 139 | args: { |
| 133 | 140 | sourceKey: v.string(), |
| @@ -143,6 +150,7 @@ export const resolveCredential = workspaceQuery({ · 9 unmodified lines | ||
| 143 | 150 | }); |
| 144 | 151 | |
| 145 | 152 | export const upsertToolSource = workspaceMutation({ |
| 153 | + method: "POST", | |
| 146 | 154 | requireAdmin: true, |
| 147 | 155 | args: { |
| 148 | 156 | id: v.optional(v.string()), |
| @@ -168,6 +176,7 @@ export const upsertToolSource = workspaceMutation({ · 19 unmodified lines | ||
| 168 | 176 | }); |
| 169 | 177 | |
| 170 | 178 | export const listToolSources = workspaceQuery({ |
| 179 | + method: "GET", | |
| 171 | 180 | args: {}, |
| 172 | 181 | handler: async (ctx) => { |
| 173 | 182 | return await ctx.runQuery(internal.database.listToolSources, { |
| @@ -177,6 +186,7 @@ export const listToolSources = workspaceQuery({ · 3 unmodified lines | ||
| 177 | 186 | }); |
| 178 | 187 | |
| 179 | 188 | export const deleteToolSource = workspaceMutation({ |
| 189 | + method: "POST", | |
| 180 | 190 | requireAdmin: true, |
| 181 | 191 | args: { |
| 182 | 192 | sourceId: v.string(), |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | } from "../src/workspaces/handlers"; |
| 8 | 8 | |
| 9 | 9 | export const create = authedMutation({ |
| 10 | + method: "POST", | |
| 10 | 11 | args: { |
| 11 | 12 | name: v.string(), |
| 12 | 13 | organizationId: v.optional(v.id("organizations")), |
| @@ -18,6 +19,7 @@ export const create = authedMutation({ · 5 unmodified lines | ||
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | 21 | export const list = optionalAccountQuery({ |
| 22 | + method: "GET", | |
| 21 | 23 | args: { |
| 22 | 24 | organizationId: v.optional(v.id("organizations")), |
| 23 | 25 | }, |
| @@ -27,6 +29,7 @@ export const list = optionalAccountQuery({ · 3 unmodified lines | ||
| 27 | 29 | }); |
| 28 | 30 | |
| 29 | 31 | export const generateWorkspaceIconUploadUrl = authedMutation({ |
| 32 | + method: "POST", | |
| 30 | 33 | args: {}, |
| 31 | 34 | handler: async (ctx) => { |
| 32 | 35 | return await generateWorkspaceIconUploadUrlHandler(ctx); |