Repair migrated source recipes on bootstrap Add source recipe regression coverage
Unified · Split
| @@ -0,0 +1,491 @@ | ||
| 1 | +ALTER TABLE "sources" ADD COLUMN "recipe_id" text;--> statement-breakpoint | |
| 2 | +ALTER TABLE "sources" ADD COLUMN "recipe_revision_id" text;--> statement-breakpoint | |
| 3 | +ALTER TABLE "sources" ADD COLUMN "binding_config_json" text;--> statement-breakpoint | |
| 4 | + | |
| 5 | +CREATE TABLE "source_recipes" ( | |
| 6 | + "id" text PRIMARY KEY NOT NULL, | |
| 7 | + "kind" text NOT NULL, | |
| 8 | + "importer_kind" text NOT NULL, | |
| 9 | + "provider_key" text NOT NULL, | |
| 10 | + "name" text NOT NULL, | |
| 11 | + "summary" text, | |
| 12 | + "visibility" text NOT NULL, | |
| 13 | + "latest_revision_id" text NOT NULL, | |
| 14 | + "created_at" bigint NOT NULL, | |
| 15 | + "updated_at" bigint NOT NULL, | |
| 16 | + CONSTRAINT "source_recipes_kind_check" CHECK ("kind" in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')), | |
| 17 | + CONSTRAINT "source_recipes_importer_kind_check" CHECK ("importer_kind" in ( | |
| 18 | + 'openapi', | |
| 19 | + 'google_discovery', | |
| 20 | + 'postman_collection', | |
| 21 | + 'snippet_bundle', | |
| 22 | + 'graphql_introspection', | |
| 23 | + 'mcp_manifest', | |
| 24 | + 'internal_manifest' | |
| 25 | + )), | |
| 26 | + CONSTRAINT "source_recipes_visibility_check" CHECK ("visibility" in ('private', 'workspace', 'organization', 'public')) | |
| 27 | +);--> statement-breakpoint | |
| 28 | + | |
| 29 | +CREATE TABLE "source_recipe_revisions" ( | |
| 30 | + "id" text PRIMARY KEY NOT NULL, | |
| 31 | + "recipe_id" text NOT NULL, | |
| 32 | + "revision_number" bigint NOT NULL, | |
| 33 | + "source_config_json" text NOT NULL, | |
| 34 | + "manifest_json" text, | |
| 35 | + "manifest_hash" text, | |
| 36 | + "created_at" bigint NOT NULL, | |
| 37 | + "updated_at" bigint NOT NULL | |
| 38 | +);--> statement-breakpoint | |
| 39 | + | |
| 40 | +CREATE TABLE "source_recipe_documents" ( | |
| 41 | + "id" text PRIMARY KEY NOT NULL, | |
| 42 | + "recipe_revision_id" text NOT NULL, | |
| 43 | + "document_kind" text NOT NULL, | |
| 44 | + "document_key" text NOT NULL, | |
| 45 | + "content_text" text NOT NULL, | |
| 46 | + "content_hash" text NOT NULL, | |
| 47 | + "fetched_at" bigint, | |
| 48 | + "created_at" bigint NOT NULL, | |
| 49 | + "updated_at" bigint NOT NULL, | |
| 50 | + CONSTRAINT "source_recipe_documents_kind_check" CHECK ("document_kind" in ( | |
| 51 | + 'google_discovery', | |
| 52 | + 'openapi', | |
| 53 | + 'postman_collection', | |
| 54 | + 'postman_environment', | |
| 55 | + 'graphql_introspection', | |
| 56 | + 'mcp_manifest' | |
| 57 | + )) | |
| 58 | +);--> statement-breakpoint | |
| 59 | + | |
| 60 | +CREATE TABLE "source_recipe_operations" ( | |
| 61 | + "id" text PRIMARY KEY NOT NULL, | |
| 62 | + "recipe_revision_id" text NOT NULL, | |
| 63 | + "operation_key" text NOT NULL, | |
| 64 | + "transport_kind" text NOT NULL, | |
| 65 | + "tool_id" text NOT NULL, | |
| 66 | + "title" text, | |
| 67 | + "description" text, | |
| 68 | + "operation_kind" text NOT NULL, | |
| 69 | + "search_text" text NOT NULL, | |
| 70 | + "input_schema_json" text, | |
| 71 | + "output_schema_json" text, | |
| 72 | + "provider_kind" text NOT NULL, | |
| 73 | + "provider_data_json" text, | |
| 74 | + "mcp_tool_name" text, | |
| 75 | + "openapi_method" text, | |
| 76 | + "openapi_path_template" text, | |
| 77 | + "openapi_operation_hash" text, | |
| 78 | + "openapi_raw_tool_id" text, | |
| 79 | + "openapi_operation_id" text, | |
| 80 | + "openapi_tags_json" text, | |
| 81 | + "openapi_request_body_required" boolean, | |
| 82 | + "graphql_operation_type" text, | |
| 83 | + "graphql_operation_name" text, | |
| 84 | + "created_at" bigint NOT NULL, | |
| 85 | + "updated_at" bigint NOT NULL, | |
| 86 | + CONSTRAINT "source_recipe_operations_transport_kind_check" CHECK ("transport_kind" in ('http', 'graphql', 'mcp', 'internal')), | |
| 87 | + CONSTRAINT "source_recipe_operations_kind_check" CHECK ("operation_kind" in ('read', 'write', 'delete', 'unknown')), | |
| 88 | + CONSTRAINT "source_recipe_operations_provider_kind_check" CHECK ("provider_kind" in ('mcp', 'openapi', 'graphql', 'internal')) | |
| 89 | +);--> statement-breakpoint | |
| 90 | + | |
| 91 | +CREATE TABLE "workspace_source_oauth_clients" ( | |
| 92 | + "id" text PRIMARY KEY NOT NULL, | |
| 93 | + "workspace_id" text NOT NULL, | |
| 94 | + "source_id" text NOT NULL, | |
| 95 | + "provider_key" text NOT NULL, | |
| 96 | + "client_id" text NOT NULL, | |
| 97 | + "client_secret_provider_id" text, | |
| 98 | + "client_secret_handle" text, | |
| 99 | + "client_metadata_json" text, | |
| 100 | + "created_at" bigint NOT NULL, | |
| 101 | + "updated_at" bigint NOT NULL | |
| 102 | +);--> statement-breakpoint | |
| 103 | + | |
| 104 | +UPDATE "sources" | |
| 105 | +SET | |
| 106 | + "recipe_id" = 'src_recipe_' || md5("workspace_id" || ':' || "source_id"), | |
| 107 | + "recipe_revision_id" = 'src_recipe_rev_' || md5("workspace_id" || ':' || "source_id"), | |
| 108 | + "binding_config_json" = null;--> statement-breakpoint | |
| 109 | + | |
| 110 | +INSERT INTO "source_recipes" ( | |
| 111 | + "id", | |
| 112 | + "kind", | |
| 113 | + "importer_kind", | |
| 114 | + "provider_key", | |
| 115 | + "name", | |
| 116 | + "summary", | |
| 117 | + "visibility", | |
| 118 | + "latest_revision_id", | |
| 119 | + "created_at", | |
| 120 | + "updated_at" | |
| 121 | +) | |
| 122 | +SELECT | |
| 123 | + "s"."recipe_id", | |
| 124 | + CASE | |
| 125 | + WHEN "s"."kind" = 'openapi' THEN 'http_recipe' | |
| 126 | + WHEN "s"."kind" = 'graphql' THEN 'graphql_recipe' | |
| 127 | + WHEN "s"."kind" = 'mcp' THEN 'mcp_recipe' | |
| 128 | + ELSE 'internal_recipe' | |
| 129 | + END, | |
| 130 | + CASE | |
| 131 | + WHEN "s"."kind" = 'openapi' THEN 'openapi' | |
| 132 | + WHEN "s"."kind" = 'graphql' THEN 'graphql_introspection' | |
| 133 | + WHEN "s"."kind" = 'mcp' THEN 'mcp_manifest' | |
| 134 | + ELSE 'internal_manifest' | |
| 135 | + END, | |
| 136 | + CASE | |
| 137 | + WHEN "s"."kind" = 'openapi' THEN 'generic_http' | |
| 138 | + WHEN "s"."kind" = 'graphql' THEN 'generic_graphql' | |
| 139 | + WHEN "s"."kind" = 'mcp' THEN 'generic_mcp' | |
| 140 | + ELSE 'generic_internal' | |
| 141 | + END, | |
| 142 | + "s"."name", | |
| 143 | + null, | |
| 144 | + 'workspace', | |
| 145 | + "s"."recipe_revision_id", | |
| 146 | + "s"."created_at", | |
| 147 | + "s"."updated_at" | |
| 148 | +FROM "sources" AS "s";--> statement-breakpoint | |
| 149 | + | |
| 150 | +INSERT INTO "source_recipe_revisions" ( | |
| 151 | + "id", | |
| 152 | + "recipe_id", | |
| 153 | + "revision_number", | |
| 154 | + "source_config_json", | |
| 155 | + "manifest_json", | |
| 156 | + "manifest_hash", | |
| 157 | + "created_at", | |
| 158 | + "updated_at" | |
| 159 | +) | |
| 160 | +SELECT | |
| 161 | + "s"."recipe_revision_id", | |
| 162 | + "s"."recipe_id", | |
| 163 | + 1, | |
| 164 | + CASE | |
| 165 | + WHEN "s"."kind" = 'mcp' THEN jsonb_build_object( | |
| 166 | + 'kind', 'mcp', | |
| 167 | + 'endpoint', "s"."endpoint", | |
| 168 | + 'transport', "s"."transport", | |
| 169 | + 'queryParams', COALESCE("s"."query_params_json"::jsonb, 'null'::jsonb), | |
| 170 | + 'headers', COALESCE("s"."headers_json"::jsonb, 'null'::jsonb) | |
| 171 | + )::text | |
| 172 | + WHEN "s"."kind" = 'openapi' THEN jsonb_build_object( | |
| 173 | + 'kind', 'openapi', | |
| 174 | + 'endpoint', "s"."endpoint", | |
| 175 | + 'specUrl', COALESCE("s"."spec_url", "s"."endpoint"), | |
| 176 | + 'defaultHeaders', COALESCE("s"."default_headers_json"::jsonb, 'null'::jsonb) | |
| 177 | + )::text | |
| 178 | + WHEN "s"."kind" = 'graphql' THEN jsonb_build_object( | |
| 179 | + 'kind', 'graphql', | |
| 180 | + 'endpoint', "s"."endpoint", | |
| 181 | + 'defaultHeaders', COALESCE("s"."default_headers_json"::jsonb, 'null'::jsonb) | |
| 182 | + )::text | |
| 183 | + ELSE jsonb_build_object( | |
| 184 | + 'kind', 'internal', | |
| 185 | + 'endpoint', "s"."endpoint" | |
| 186 | + )::text | |
| 187 | + END, | |
| 188 | + null, | |
| 189 | + "s"."source_hash", | |
| 190 | + "s"."created_at", | |
| 191 | + "s"."updated_at" | |
| 192 | +FROM "sources" AS "s";--> statement-breakpoint | |
| 193 | + | |
| 194 | +INSERT INTO "source_recipe_documents" ( | |
| 195 | + "id", | |
| 196 | + "recipe_revision_id", | |
| 197 | + "document_kind", | |
| 198 | + "document_key", | |
| 199 | + "content_text", | |
| 200 | + "content_hash", | |
| 201 | + "fetched_at", | |
| 202 | + "created_at", | |
| 203 | + "updated_at" | |
| 204 | +) | |
| 205 | +SELECT | |
| 206 | + 'src_recipe_doc_' || md5("s"."workspace_id" || ':' || "s"."source_id" || ':primary'), | |
| 207 | + "s"."recipe_revision_id", | |
| 208 | + CASE | |
| 209 | + WHEN "s"."kind" = 'openapi' THEN 'openapi' | |
| 210 | + WHEN "s"."kind" = 'graphql' THEN 'graphql_introspection' | |
| 211 | + ELSE 'mcp_manifest' | |
| 212 | + END, | |
| 213 | + CASE | |
| 214 | + WHEN "s"."kind" = 'openapi' THEN COALESCE("s"."spec_url", "s"."endpoint") | |
| 215 | + ELSE "s"."endpoint" | |
| 216 | + END, | |
| 217 | + "s"."source_document_text", | |
| 218 | + md5("s"."source_document_text"), | |
| 219 | + "s"."updated_at", | |
| 220 | + "s"."created_at", | |
| 221 | + "s"."updated_at" | |
| 222 | +FROM "sources" AS "s" | |
| 223 | +WHERE "s"."source_document_text" IS NOT NULL;--> statement-breakpoint | |
| 224 | + | |
| 225 | +INSERT INTO "source_recipe_operations" ( | |
| 226 | + "id", | |
| 227 | + "recipe_revision_id", | |
| 228 | + "operation_key", | |
| 229 | + "transport_kind", | |
| 230 | + "tool_id", | |
| 231 | + "title", | |
| 232 | + "description", | |
| 233 | + "operation_kind", | |
| 234 | + "search_text", | |
| 235 | + "input_schema_json", | |
| 236 | + "output_schema_json", | |
| 237 | + "provider_kind", | |
| 238 | + "provider_data_json", | |
| 239 | + "mcp_tool_name", | |
| 240 | + "openapi_method", | |
| 241 | + "openapi_path_template", | |
| 242 | + "openapi_operation_hash", | |
| 243 | + "openapi_raw_tool_id", | |
| 244 | + "openapi_operation_id", | |
| 245 | + "openapi_tags_json", | |
| 246 | + "openapi_request_body_required", | |
| 247 | + "graphql_operation_type", | |
| 248 | + "graphql_operation_name", | |
| 249 | + "created_at", | |
| 250 | + "updated_at" | |
| 251 | +) | |
| 252 | +SELECT | |
| 253 | + 'src_recipe_op_' || md5("ta"."workspace_id" || ':' || "ta"."source_id" || ':' || "ta"."path"), | |
| 254 | + "s"."recipe_revision_id", | |
| 255 | + "ta"."tool_id", | |
| 256 | + CASE | |
| 257 | + WHEN "ta"."provider_kind" = 'openapi' THEN 'http' | |
| 258 | + ELSE 'mcp' | |
| 259 | + END, | |
| 260 | + "ta"."tool_id", | |
| 261 | + "ta"."title", | |
| 262 | + "ta"."description", | |
| 263 | + CASE | |
| 264 | + WHEN "ta"."provider_kind" = 'openapi' AND upper(COALESCE("ta"."openapi_method", '')) IN ('GET', 'HEAD') THEN 'read' | |
| 265 | + WHEN "ta"."provider_kind" = 'openapi' AND upper(COALESCE("ta"."openapi_method", '')) = 'DELETE' THEN 'delete' | |
| 266 | + WHEN "ta"."provider_kind" = 'openapi' THEN 'write' | |
| 267 | + ELSE 'unknown' | |
| 268 | + END, | |
| 269 | + "ta"."search_text", | |
| 270 | + "ta"."input_schema_json", | |
| 271 | + "ta"."output_schema_json", | |
| 272 | + "ta"."provider_kind", | |
| 273 | + CASE | |
| 274 | + WHEN "ta"."provider_kind" = 'mcp' THEN jsonb_build_object( | |
| 275 | + 'kind', 'mcp', | |
| 276 | + 'toolId', "ta"."tool_id", | |
| 277 | + 'toolName', COALESCE("ta"."mcp_tool_name", "ta"."title", "ta"."path"), | |
| 278 | + 'description', "ta"."description" | |
| 279 | + )::text | |
| 280 | + ELSE jsonb_build_object( | |
| 281 | + 'kind', 'openapi', | |
| 282 | + 'toolId', "ta"."tool_id", | |
| 283 | + 'rawToolId', "ta"."openapi_raw_tool_id", | |
| 284 | + 'operationId', "ta"."openapi_operation_id", | |
| 285 | + 'tags', COALESCE("ta"."openapi_tags_json"::jsonb, '[]'::jsonb), | |
| 286 | + 'method', "ta"."openapi_method", | |
| 287 | + 'path', "ta"."openapi_path_template", | |
| 288 | + 'operationHash', "ta"."openapi_operation_hash" | |
| 289 | + )::text | |
| 290 | + END, | |
| 291 | + "ta"."mcp_tool_name", | |
| 292 | + "ta"."openapi_method", | |
| 293 | + "ta"."openapi_path_template", | |
| 294 | + "ta"."openapi_operation_hash", | |
| 295 | + "ta"."openapi_raw_tool_id", | |
| 296 | + "ta"."openapi_operation_id", | |
| 297 | + "ta"."openapi_tags_json", | |
| 298 | + "ta"."openapi_request_body_required", | |
| 299 | + null, | |
| 300 | + null, | |
| 301 | + "ta"."created_at", | |
| 302 | + "ta"."updated_at" | |
| 303 | +FROM "tool_artifacts" AS "ta" | |
| 304 | +JOIN "sources" AS "s" | |
| 305 | + ON "s"."workspace_id" = "ta"."workspace_id" | |
| 306 | + AND "s"."source_id" = "ta"."source_id";--> statement-breakpoint | |
| 307 | + | |
| 308 | +WITH "mcp_manifests" AS ( | |
| 309 | + SELECT | |
| 310 | + "s"."workspace_id", | |
| 311 | + "s"."source_id", | |
| 312 | + "s"."recipe_revision_id", | |
| 313 | + jsonb_build_object( | |
| 314 | + 'version', 1, | |
| 315 | + 'tools', COALESCE( | |
| 316 | + jsonb_agg( | |
| 317 | + jsonb_build_object( | |
| 318 | + 'toolId', "ta"."tool_id", | |
| 319 | + 'toolName', COALESCE("ta"."mcp_tool_name", "ta"."title", "ta"."path"), | |
| 320 | + 'description', "ta"."description", | |
| 321 | + 'inputSchemaJson', "ta"."input_schema_json", | |
| 322 | + 'outputSchemaJson', "ta"."output_schema_json" | |
| 323 | + ) | |
| 324 | + ORDER BY "ta"."path" | |
| 325 | + ) FILTER (WHERE "ta"."provider_kind" = 'mcp'), | |
| 326 | + '[]'::jsonb | |
| 327 | + ) | |
| 328 | + )::text AS "manifest_json" | |
| 329 | + FROM "sources" AS "s" | |
| 330 | + LEFT JOIN "tool_artifacts" AS "ta" | |
| 331 | + ON "ta"."workspace_id" = "s"."workspace_id" | |
| 332 | + AND "ta"."source_id" = "s"."source_id" | |
| 333 | + WHERE "s"."kind" = 'mcp' | |
| 334 | + GROUP BY "s"."workspace_id", "s"."source_id", "s"."recipe_revision_id" | |
| 335 | +) | |
| 336 | +UPDATE "source_recipe_revisions" AS "rev" | |
| 337 | +SET | |
| 338 | + "manifest_json" = "mcp"."manifest_json", | |
| 339 | + "manifest_hash" = md5("mcp"."manifest_json") | |
| 340 | +FROM "mcp_manifests" AS "mcp" | |
| 341 | +WHERE "rev"."id" = "mcp"."recipe_revision_id";--> statement-breakpoint | |
| 342 | + | |
| 343 | +INSERT INTO "source_recipe_documents" ( | |
| 344 | + "id", | |
| 345 | + "recipe_revision_id", | |
| 346 | + "document_kind", | |
| 347 | + "document_key", | |
| 348 | + "content_text", | |
| 349 | + "content_hash", | |
| 350 | + "fetched_at", | |
| 351 | + "created_at", | |
| 352 | + "updated_at" | |
| 353 | +) | |
| 354 | +SELECT | |
| 355 | + 'src_recipe_doc_' || md5("s"."workspace_id" || ':' || "s"."source_id" || ':mcp_manifest'), | |
| 356 | + "s"."recipe_revision_id", | |
| 357 | + 'mcp_manifest', | |
| 358 | + "s"."endpoint", | |
| 359 | + "rev"."manifest_json", | |
| 360 | + md5("rev"."manifest_json"), | |
| 361 | + "s"."updated_at", | |
| 362 | + "s"."created_at", | |
| 363 | + "s"."updated_at" | |
| 364 | +FROM "sources" AS "s" | |
| 365 | +JOIN "source_recipe_revisions" AS "rev" | |
| 366 | + ON "rev"."id" = "s"."recipe_revision_id" | |
| 367 | +WHERE "s"."kind" = 'mcp' | |
| 368 | + AND "rev"."manifest_json" IS NOT NULL | |
| 369 | + AND NOT EXISTS ( | |
| 370 | + SELECT 1 | |
| 371 | + FROM "source_recipe_documents" AS "doc" | |
| 372 | + WHERE "doc"."recipe_revision_id" = "s"."recipe_revision_id" | |
| 373 | + AND "doc"."document_kind" = 'mcp_manifest' | |
| 374 | + AND "doc"."document_key" = "s"."endpoint" | |
| 375 | + );--> statement-breakpoint | |
| 376 | + | |
| 377 | +ALTER TABLE "sources" ALTER COLUMN "recipe_id" SET NOT NULL;--> statement-breakpoint | |
| 378 | +ALTER TABLE "sources" ALTER COLUMN "recipe_revision_id" SET NOT NULL;--> statement-breakpoint | |
| 379 | +CREATE INDEX "sources_workspace_recipe_idx" ON "sources" ("workspace_id","recipe_id","updated_at","source_id");--> statement-breakpoint | |
| 380 | + | |
| 381 | +CREATE TABLE "workspace_source_credentials" ( | |
| 382 | + "id" text PRIMARY KEY NOT NULL, | |
| 383 | + "workspace_id" text NOT NULL, | |
| 384 | + "source_id" text NOT NULL, | |
| 385 | + "actor_account_id" text, | |
| 386 | + "auth_kind" text NOT NULL, | |
| 387 | + "auth_header_name" text NOT NULL, | |
| 388 | + "auth_prefix" text NOT NULL, | |
| 389 | + "token_provider_id" text NOT NULL, | |
| 390 | + "token_handle" text NOT NULL, | |
| 391 | + "refresh_token_provider_id" text, | |
| 392 | + "refresh_token_handle" text, | |
| 393 | + "created_at" bigint NOT NULL, | |
| 394 | + "updated_at" bigint NOT NULL, | |
| 395 | + CONSTRAINT "credentials_auth_kind_check" CHECK ("auth_kind" in ('bearer', 'oauth2')) | |
| 396 | +);--> statement-breakpoint | |
| 397 | + | |
| 398 | +INSERT INTO "workspace_source_credentials" ( | |
| 399 | + "id", | |
| 400 | + "workspace_id", | |
| 401 | + "source_id", | |
| 402 | + "actor_account_id", | |
| 403 | + "auth_kind", | |
| 404 | + "auth_header_name", | |
| 405 | + "auth_prefix", | |
| 406 | + "token_provider_id", | |
| 407 | + "token_handle", | |
| 408 | + "refresh_token_provider_id", | |
| 409 | + "refresh_token_handle", | |
| 410 | + "created_at", | |
| 411 | + "updated_at" | |
| 412 | +) | |
| 413 | +SELECT | |
| 414 | + CASE | |
| 415 | + WHEN count(*) OVER (PARTITION BY "c"."id") = 1 THEN "c"."id" | |
| 416 | + ELSE "c"."id" || ':' || "scb"."source_id" | |
| 417 | + END, | |
| 418 | + "c"."workspace_id", | |
| 419 | + "scb"."source_id", | |
| 420 | + null, | |
| 421 | + "c"."auth_kind", | |
| 422 | + "c"."auth_header_name", | |
| 423 | + "c"."auth_prefix", | |
| 424 | + "c"."token_provider_id", | |
| 425 | + "c"."token_handle", | |
| 426 | + "c"."refresh_token_provider_id", | |
| 427 | + "c"."refresh_token_handle", | |
| 428 | + "c"."created_at", | |
| 429 | + "c"."updated_at" | |
| 430 | +FROM "credentials" AS "c" | |
| 431 | +JOIN "source_credential_bindings" AS "scb" | |
| 432 | + ON "scb"."workspace_id" = "c"."workspace_id" | |
| 433 | + AND "scb"."credential_id" = "c"."id";--> statement-breakpoint | |
| 434 | + | |
| 435 | +DROP TABLE "credentials";--> statement-breakpoint | |
| 436 | +DROP TABLE "source_credential_bindings";--> statement-breakpoint | |
| 437 | +CREATE INDEX "credentials_workspace_idx" ON "workspace_source_credentials" ("workspace_id","updated_at","id");--> statement-breakpoint | |
| 438 | +CREATE UNIQUE INDEX "credentials_workspace_source_actor_idx" ON "workspace_source_credentials" ("workspace_id","source_id","actor_account_id");--> statement-breakpoint | |
| 439 | +CREATE INDEX "credentials_workspace_source_idx" ON "workspace_source_credentials" ("workspace_id","source_id","updated_at","id");--> statement-breakpoint | |
| 440 | + | |
| 441 | +ALTER TABLE "source_auth_sessions" ADD COLUMN "actor_account_id" text;--> statement-breakpoint | |
| 442 | +ALTER TABLE "source_auth_sessions" ADD COLUMN "provider_kind" text;--> statement-breakpoint | |
| 443 | +ALTER TABLE "source_auth_sessions" ADD COLUMN "session_data_json" text;--> statement-breakpoint | |
| 444 | + | |
| 445 | +UPDATE "source_auth_sessions" | |
| 446 | +SET | |
| 447 | + "provider_kind" = 'mcp_oauth', | |
| 448 | + "session_data_json" = jsonb_build_object( | |
| 449 | + 'kind', 'mcp_oauth', | |
| 450 | + 'endpoint', "endpoint", | |
| 451 | + 'redirectUri', "redirect_uri", | |
| 452 | + 'scope', "scope", | |
| 453 | + 'resourceMetadataUrl', "resource_metadata_url", | |
| 454 | + 'authorizationServerUrl', "authorization_server_url", | |
| 455 | + 'resourceMetadataJson', "resource_metadata_json", | |
| 456 | + 'authorizationServerMetadataJson', "authorization_server_metadata_json", | |
| 457 | + 'clientInformationJson', "client_information_json", | |
| 458 | + 'codeVerifier', "code_verifier", | |
| 459 | + 'authorizationUrl', "authorization_url" | |
| 460 | + )::text;--> statement-breakpoint | |
| 461 | + | |
| 462 | +ALTER TABLE "source_auth_sessions" ALTER COLUMN "provider_kind" SET NOT NULL;--> statement-breakpoint | |
| 463 | +ALTER TABLE "source_auth_sessions" ALTER COLUMN "session_data_json" SET NOT NULL;--> statement-breakpoint | |
| 464 | +DROP INDEX IF EXISTS "source_auth_sessions_pending_idx";--> statement-breakpoint | |
| 465 | +ALTER TABLE "source_auth_sessions" DROP CONSTRAINT "source_auth_sessions_strategy_check";--> statement-breakpoint | |
| 466 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "strategy";--> statement-breakpoint | |
| 467 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "endpoint";--> statement-breakpoint | |
| 468 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "redirect_uri";--> statement-breakpoint | |
| 469 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "scope";--> statement-breakpoint | |
| 470 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "resource_metadata_url";--> statement-breakpoint | |
| 471 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_server_url";--> statement-breakpoint | |
| 472 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "resource_metadata_json";--> statement-breakpoint | |
| 473 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_server_metadata_json";--> statement-breakpoint | |
| 474 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "client_information_json";--> statement-breakpoint | |
| 475 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "code_verifier";--> statement-breakpoint | |
| 476 | +ALTER TABLE "source_auth_sessions" DROP COLUMN "authorization_url";--> statement-breakpoint | |
| 477 | +ALTER TABLE "source_auth_sessions" ADD CONSTRAINT "source_auth_sessions_provider_kind_check" CHECK ("provider_kind" in ('mcp_oauth', 'oauth2_pkce'));--> statement-breakpoint | |
| 478 | +CREATE INDEX "source_auth_sessions_pending_idx" ON "source_auth_sessions" ("workspace_id","source_id","actor_account_id","status","updated_at","id");--> statement-breakpoint | |
| 479 | + | |
| 480 | +CREATE INDEX "source_recipes_provider_updated_idx" ON "source_recipes" ("provider_key","updated_at","id");--> statement-breakpoint | |
| 481 | +CREATE INDEX "source_recipes_visibility_updated_idx" ON "source_recipes" ("visibility","updated_at","id");--> statement-breakpoint | |
| 482 | +CREATE UNIQUE INDEX "source_recipe_revisions_recipe_revision_idx" ON "source_recipe_revisions" ("recipe_id","revision_number");--> statement-breakpoint | |
| 483 | +CREATE UNIQUE INDEX "source_recipe_revisions_recipe_manifest_idx" ON "source_recipe_revisions" ("recipe_id","manifest_hash");--> statement-breakpoint | |
| 484 | +CREATE INDEX "source_recipe_revisions_recipe_created_idx" ON "source_recipe_revisions" ("recipe_id","created_at","id");--> statement-breakpoint | |
| 485 | +CREATE UNIQUE INDEX "source_recipe_documents_revision_kind_key_idx" ON "source_recipe_documents" ("recipe_revision_id","document_kind","document_key");--> statement-breakpoint | |
| 486 | +CREATE INDEX "source_recipe_documents_revision_created_idx" ON "source_recipe_documents" ("recipe_revision_id","created_at","id");--> statement-breakpoint | |
| 487 | +CREATE UNIQUE INDEX "source_recipe_operations_revision_operation_key_idx" ON "source_recipe_operations" ("recipe_revision_id","operation_key");--> statement-breakpoint | |
| 488 | +CREATE INDEX "source_recipe_operations_revision_tool_idx" ON "source_recipe_operations" ("recipe_revision_id","tool_id","updated_at","id");--> statement-breakpoint | |
| 489 | +CREATE INDEX "source_recipe_operations_search_text_idx" ON "source_recipe_operations" USING gin (to_tsvector('simple', "search_text"));--> statement-breakpoint | |
| 490 | +CREATE UNIQUE INDEX "workspace_source_oauth_clients_workspace_source_provider_idx" ON "workspace_source_oauth_clients" ("workspace_id","source_id","provider_key");--> statement-breakpoint | |
| 491 | +CREATE INDEX "workspace_source_oauth_clients_workspace_idx" ON "workspace_source_oauth_clients" ("workspace_id","updated_at","source_id"); | |
| @@ -241,13 +241,14 @@ export const ControlPlaneOAuthLive = HttpApiBuilder.group( · 240 unmodified lines | ||
| 241 | 241 | (handlers) => |
| 242 | 242 | handlers |
| 243 | 243 | .handle("startSourceAuth", ({ path, payload }) => |
| 244 | − withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, () => | |
| 244 | + withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, (actor) => | |
| 245 | 245 | withPolicy(requireWriteSources(path.workspaceId))( |
| 246 | 246 | Effect.gen(function* () { |
| 247 | 247 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 248 | 248 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 249 | 249 | return yield* sourceAuthService.startSourceOAuthSession({ |
| 250 | 250 | workspaceId: path.workspaceId, |
| 251 | + actorAccountId: actor.principal.accountId, | |
| 251 | 252 | baseUrl: resolveRequestOrigin(request), |
| 252 | 253 | displayName: payload.name, |
| 253 | 254 | provider: { |
| @@ -675,7 +675,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 674 unmodified lines | ||
| 675 | 675 | ), |
| 676 | 676 | ) |
| 677 | 677 | .handle("connect", ({ path, payload }) => |
| 678 | − withWorkspaceRequestActor("sources.connect", path.workspaceId, () => | |
| 678 | + withWorkspaceRequestActor("sources.connect", path.workspaceId, (actor) => | |
| 679 | 679 | withPolicy(requireWriteSources(path.workspaceId))( |
| 680 | 680 | Effect.gen(function* () { |
| 681 | 681 | const request = yield* HttpServerRequest.HttpServerRequest; |
| @@ -685,6 +685,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | ||
| 685 | 685 | if (payload.kind === "mcp") { |
| 686 | 686 | return yield* sourceAuthService.connectMcpSource({ |
| 687 | 687 | workspaceId: path.workspaceId, |
| 688 | + actorAccountId: actor.principal.accountId, | |
| 688 | 689 | endpoint: payload.endpoint, |
| 689 | 690 | name: payload.name, |
| 690 | 691 | namespace: payload.namespace, |
| @@ -699,6 +700,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 8 unmodified lines | ||
| 699 | 700 | return yield* sourceAuthService.addExecutorSource( |
| 700 | 701 | { |
| 701 | 702 | workspaceId: path.workspaceId, |
| 703 | + actorAccountId: actor.principal.accountId, | |
| 702 | 704 | executionId: null, |
| 703 | 705 | interactionId: null, |
| 704 | 706 | kind: "openapi", |
| @@ -715,6 +717,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 10 unmodified lines | ||
| 715 | 717 | return yield* sourceAuthService.addExecutorSource( |
| 716 | 718 | { |
| 717 | 719 | workspaceId: path.workspaceId, |
| 720 | + actorAccountId: actor.principal.accountId, | |
| 718 | 721 | executionId: null, |
| 719 | 722 | interactionId: null, |
| 720 | 723 | kind: "graphql", |
| @@ -9,17 +9,22 @@ import { · 8 unmodified lines | ||
| 9 | 9 | SecretMaterialIdSchema, |
| 10 | 10 | SourceAuthSessionIdSchema, |
| 11 | 11 | SourceIdSchema, |
| 12 | + SourceRecipeIdSchema, | |
| 13 | + SourceRecipeRevisionIdSchema, | |
| 12 | 14 | WorkspaceIdSchema, |
| 13 | 15 | } from "#schema"; |
| 14 | 16 | import * as Effect from "effect/Effect"; |
| 15 | 17 | import * as Option from "effect/Option"; |
| 18 | +import * as Scope from "effect/Scope"; | |
| 16 | 19 | |
| 17 | 20 | import { |
| 18 | 21 | createSqlControlPlanePersistence, |
| 19 | 22 | type SqlControlPlanePersistence, |
| 20 | 23 | } from "./index"; |
| 24 | +import { drizzleSchema } from "./schema"; | |
| 21 | 25 | |
| 22 | −const makePersistence = Effect.acquireRelease( | |
| 26 | +const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | |
| 27 | + Effect.acquireRelease( | |
| 23 | 28 | createSqlControlPlanePersistence({ |
| 24 | 29 | localDataDir: ":memory:", |
| 25 | 30 | }), |
| @@ -28,20 +33,19 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines | ||
| 28 | 33 | try: () => persistence.close(), |
| 29 | 34 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), |
| 30 | 35 | }).pipe(Effect.orDie), |
| 31 | −); | |
| 36 | + ); | |
| 32 | 37 | |
| 33 | −const seedWorkspaceCredentialState = (input: { | |
| 38 | +const seedWorkspaceSourceState = (input: { | |
| 34 | 39 | persistence: SqlControlPlanePersistence; |
| 35 | 40 | accountId: ReturnType<typeof AccountIdSchema.make>; |
| 36 | 41 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; |
| 37 | 42 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; |
| 38 | 43 | sourceId: ReturnType<typeof SourceIdSchema.make>; |
| 39 | −}) => | |
| 44 | +}): Effect.Effect<void, unknown, never> => | |
| 40 | 45 | Effect.gen(function* () { |
| 41 | 46 | const now = Date.now(); |
| 42 | − const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`); | |
| 43 | − const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`); | |
| 44 | − const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`); | |
| 47 | + const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); | |
| 48 | + const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`); | |
| 45 | 49 | |
| 46 | 50 | yield* input.persistence.rows.organizations.insert({ |
| 47 | 51 | id: input.organizationId, |
| @@ -63,6 +67,8 @@ const seedWorkspaceCredentialState = (input: { · 15 unmodified lines | ||
| 63 | 67 | yield* input.persistence.rows.sources.insert({ |
| 64 | 68 | id: input.sourceId, |
| 65 | 69 | workspaceId: input.workspaceId, |
| 70 | + recipeId, | |
| 71 | + recipeRevisionId, | |
| 66 | 72 | name: "Github", |
| 67 | 73 | kind: "openapi", |
| 68 | 74 | endpoint: "https://api.github.com", |
| @@ -70,6 +76,7 @@ const seedWorkspaceCredentialState = (input: { · 1 unmodified line | ||
| 70 | 76 | enabled: true, |
| 71 | 77 | namespace: "github", |
| 72 | 78 | transport: null, |
| 79 | + bindingConfigJson: null, | |
| 73 | 80 | queryParamsJson: null, |
| 74 | 81 | headersJson: null, |
| 75 | 82 | specUrl: "https://api.github.com/openapi.json", |
| @@ -80,6 +87,25 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines | ||
| 80 | 87 | createdAt: now, |
| 81 | 88 | updatedAt: now, |
| 82 | 89 | }); |
| 90 | + }); | |
| 91 | + | |
| 92 | +const seedWorkspaceCredentialState = (input: { | |
| 93 | + persistence: SqlControlPlanePersistence; | |
| 94 | + accountId: ReturnType<typeof AccountIdSchema.make>; | |
| 95 | + organizationId: ReturnType<typeof OrganizationIdSchema.make>; | |
| 96 | + workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | |
| 97 | + sourceId: ReturnType<typeof SourceIdSchema.make>; | |
| 98 | +}): Effect.Effect<{ | |
| 99 | + tokenId: ReturnType<typeof SecretMaterialIdSchema.make>; | |
| 100 | + refreshId: ReturnType<typeof SecretMaterialIdSchema.make>; | |
| 101 | +}, unknown, never> => | |
| 102 | + Effect.gen(function* () { | |
| 103 | + const now = Date.now(); | |
| 104 | + const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`); | |
| 105 | + const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`); | |
| 106 | + const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`); | |
| 107 | + | |
| 108 | + yield* seedWorkspaceSourceState(input); | |
| 83 | 109 | yield* input.persistence.rows.secretMaterials.upsert({ |
| 84 | 110 | id: tokenId, |
| 85 | 111 | name: null, |
| @@ -99,6 +125,8 @@ const seedWorkspaceCredentialState = (input: { · 13 unmodified lines | ||
| 99 | 125 | yield* input.persistence.rows.credentials.upsert({ |
| 100 | 126 | id: credentialId, |
| 101 | 127 | workspaceId: input.workspaceId, |
| 128 | + sourceId: input.sourceId, | |
| 129 | + actorAccountId: input.accountId, | |
| 102 | 130 | authKind: "oauth2", |
| 103 | 131 | authHeaderName: "Authorization", |
| 104 | 132 | authPrefix: "Bearer ", |
| @@ -109,33 +137,29 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines | ||
| 109 | 137 | createdAt: now, |
| 110 | 138 | updatedAt: now, |
| 111 | 139 | }); |
| 112 | − yield* input.persistence.rows.sourceCredentialBindings.upsert({ | |
| 113 | − id: `src_cred_bind_${input.workspaceId}`, | |
| 114 | − workspaceId: input.workspaceId, | |
| 115 | − sourceId: input.sourceId, | |
| 116 | − credentialId, | |
| 117 | − createdAt: now, | |
| 118 | − updatedAt: now, | |
| 119 | − }); | |
| 120 | 140 | yield* input.persistence.rows.sourceAuthSessions.upsert({ |
| 121 | 141 | id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`), |
| 122 | 142 | workspaceId: input.workspaceId, |
| 123 | 143 | sourceId: input.sourceId, |
| 144 | + actorAccountId: input.accountId, | |
| 124 | 145 | executionId: null, |
| 125 | 146 | interactionId: null, |
| 126 | − strategy: "oauth2_authorization_code", | |
| 147 | + providerKind: "mcp_oauth", | |
| 127 | 148 | status: "pending", |
| 128 | − endpoint: "https://api.github.com", | |
| 129 | 149 | state: `state_${input.workspaceId}`, |
| 130 | − redirectUri: "http://127.0.0.1/callback", | |
| 131 | − scope: null, | |
| 132 | − resourceMetadataUrl: null, | |
| 133 | − authorizationServerUrl: null, | |
| 134 | − resourceMetadataJson: null, | |
| 135 | − authorizationServerMetadataJson: null, | |
| 136 | − clientInformationJson: null, | |
| 137 | − codeVerifier: "verifier", | |
| 138 | − authorizationUrl: "https://example.com/auth", | |
| 150 | + sessionDataJson: JSON.stringify({ | |
| 151 | + kind: "mcp_oauth", | |
| 152 | + endpoint: "https://api.github.com", | |
| 153 | + redirectUri: "http://127.0.0.1/callback", | |
| 154 | + scope: null, | |
| 155 | + resourceMetadataUrl: null, | |
| 156 | + authorizationServerUrl: null, | |
| 157 | + resourceMetadataJson: null, | |
| 158 | + authorizationServerMetadataJson: null, | |
| 159 | + clientInformationJson: null, | |
| 160 | + codeVerifier: "verifier", | |
| 161 | + authorizationUrl: "https://example.com/auth", | |
| 162 | + }), | |
| 139 | 163 | errorText: null, |
| 140 | 164 | completedAt: null, |
| 141 | 165 | createdAt: now, |
| @@ -176,6 +200,8 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines | ||
| 176 | 200 | yield* persistence.rows.sources.insert({ |
| 177 | 201 | id: SourceIdSchema.make("src_1"), |
| 178 | 202 | workspaceId, |
| 203 | + recipeId: SourceRecipeIdSchema.make("src_recipe_1"), | |
| 204 | + recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"), | |
| 179 | 205 | name: "Github", |
| 180 | 206 | kind: "openapi", |
| 181 | 207 | endpoint: "https://api.github.com", |
| @@ -183,6 +209,7 @@ describe("control-plane-persistence-drizzle", () => { · 1 unmodified line | ||
| 183 | 209 | enabled: true, |
| 184 | 210 | namespace: "github", |
| 185 | 211 | transport: null, |
| 212 | + bindingConfigJson: null, | |
| 186 | 213 | queryParamsJson: null, |
| 187 | 214 | headersJson: null, |
| 188 | 215 | specUrl: "https://api.github.com/openapi.json", |
| @@ -271,6 +298,137 @@ describe("control-plane-persistence-drizzle", () => { · 82 unmodified lines | ||
| 271 | 298 | }), |
| 272 | 299 | ); |
| 273 | 300 | |
| 301 | + it.scoped("deduplicates null-actor credentials and returns actor/shared matches", () => | |
| 302 | + Effect.gen(function* () { | |
| 303 | + const persistence = yield* makePersistence; | |
| 304 | + const accountId = AccountIdSchema.make("acc_credentials"); | |
| 305 | + const organizationId = OrganizationIdSchema.make("org_credentials"); | |
| 306 | + const workspaceId = WorkspaceIdSchema.make("ws_credentials"); | |
| 307 | + const sourceId = SourceIdSchema.make("src_credentials"); | |
| 308 | + const actorCredentialId = CredentialIdSchema.make("cred_actor_credentials"); | |
| 309 | + const nullCredentialA = CredentialIdSchema.make("cred_null_credentials_a"); | |
| 310 | + const nullCredentialB = CredentialIdSchema.make("cred_null_credentials_b"); | |
| 311 | + const now = Date.now(); | |
| 312 | + | |
| 313 | + yield* seedWorkspaceSourceState({ | |
| 314 | + persistence, | |
| 315 | + accountId, | |
| 316 | + organizationId, | |
| 317 | + workspaceId, | |
| 318 | + sourceId, | |
| 319 | + }); | |
| 320 | + | |
| 321 | + yield* Effect.tryPromise(async () => { | |
| 322 | + await persistence.db.insert(drizzleSchema.credentialsTable).values([ | |
| 323 | + { | |
| 324 | + id: nullCredentialA, | |
| 325 | + workspaceId, | |
| 326 | + sourceId, | |
| 327 | + actorAccountId: null, | |
| 328 | + authKind: "bearer", | |
| 329 | + authHeaderName: "Authorization", | |
| 330 | + authPrefix: "Bearer ", | |
| 331 | + tokenProviderId: "postgres", | |
| 332 | + tokenHandle: "sec_null_a", | |
| 333 | + refreshTokenProviderId: null, | |
| 334 | + refreshTokenHandle: null, | |
| 335 | + createdAt: now, | |
| 336 | + updatedAt: now, | |
| 337 | + }, | |
| 338 | + { | |
| 339 | + id: nullCredentialB, | |
| 340 | + workspaceId, | |
| 341 | + sourceId, | |
| 342 | + actorAccountId: null, | |
| 343 | + authKind: "bearer", | |
| 344 | + authHeaderName: "Authorization", | |
| 345 | + authPrefix: "Bearer ", | |
| 346 | + tokenProviderId: "postgres", | |
| 347 | + tokenHandle: "sec_null_b", | |
| 348 | + refreshTokenProviderId: null, | |
| 349 | + refreshTokenHandle: null, | |
| 350 | + createdAt: now + 1, | |
| 351 | + updatedAt: now + 1, | |
| 352 | + }, | |
| 353 | + ]); | |
| 354 | + }).pipe(Effect.orDie); | |
| 355 | + | |
| 356 | + yield* persistence.rows.credentials.upsert({ | |
| 357 | + id: CredentialIdSchema.make("cred_null_credentials_replacement"), | |
| 358 | + workspaceId, | |
| 359 | + sourceId, | |
| 360 | + actorAccountId: null, | |
| 361 | + authKind: "oauth2", | |
| 362 | + authHeaderName: "X-Auth", | |
| 363 | + authPrefix: "Token ", | |
| 364 | + tokenProviderId: "postgres", | |
| 365 | + tokenHandle: "sec_null_replacement", | |
| 366 | + refreshTokenProviderId: "postgres", | |
| 367 | + refreshTokenHandle: "sec_null_refresh", | |
| 368 | + createdAt: now + 2, | |
| 369 | + updatedAt: now + 2, | |
| 370 | + }); | |
| 371 | + | |
| 372 | + yield* persistence.rows.credentials.upsert({ | |
| 373 | + id: actorCredentialId, | |
| 374 | + workspaceId, | |
| 375 | + sourceId, | |
| 376 | + actorAccountId: accountId, | |
| 377 | + authKind: "bearer", | |
| 378 | + authHeaderName: "Authorization", | |
| 379 | + authPrefix: "Bearer ", | |
| 380 | + tokenProviderId: "postgres", | |
| 381 | + tokenHandle: "sec_actor", | |
| 382 | + refreshTokenProviderId: null, | |
| 383 | + refreshTokenHandle: null, | |
| 384 | + createdAt: now + 3, | |
| 385 | + updatedAt: now + 3, | |
| 386 | + }); | |
| 387 | + | |
| 388 | + const allCredentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({ | |
| 389 | + workspaceId, | |
| 390 | + sourceId, | |
| 391 | + }); | |
| 392 | + expect(allCredentials).toHaveLength(2); | |
| 393 | + const nullActorCredentials = allCredentials.filter((credential) => credential.actorAccountId === null); | |
| 394 | + expect(nullActorCredentials).toHaveLength(1); | |
| 395 | + expect(nullActorCredentials[0]?.id).toBe(nullCredentialA); | |
| 396 | + expect(nullActorCredentials[0]?.authKind).toBe("oauth2"); | |
| 397 | + expect(nullActorCredentials[0]?.authHeaderName).toBe("X-Auth"); | |
| 398 | + expect(nullActorCredentials[0]?.tokenHandle).toBe("sec_null_replacement"); | |
| 399 | + expect(allCredentials.map((credential) => credential.id).sort()).toEqual( | |
| 400 | + [actorCredentialId, nullCredentialA].sort(), | |
| 401 | + ); | |
| 402 | + | |
| 403 | + const forActor = yield* persistence.rows.credentials.listByWorkspaceSourceAndActor({ | |
| 404 | + workspaceId, | |
| 405 | + sourceId, | |
| 406 | + actorAccountId: accountId, | |
| 407 | + }); | |
| 408 | + expect(forActor).toHaveLength(2); | |
| 409 | + expect(new Set(forActor.map((credential) => credential.id))).toEqual( | |
| 410 | + new Set([actorCredentialId, nullCredentialA]), | |
| 411 | + ); | |
| 412 | + | |
| 413 | + const nullActorOnly = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({ | |
| 414 | + workspaceId, | |
| 415 | + sourceId, | |
| 416 | + actorAccountId: null, | |
| 417 | + }); | |
| 418 | + assertTrue(Option.isSome(nullActorOnly)); | |
| 419 | + if (Option.isSome(nullActorOnly)) { | |
| 420 | + expect(nullActorOnly.value.id).toBe(nullCredentialA); | |
| 421 | + } | |
| 422 | + | |
| 423 | + const missingActor = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({ | |
| 424 | + workspaceId, | |
| 425 | + sourceId, | |
| 426 | + actorAccountId: AccountIdSchema.make("acc_missing_credentials"), | |
| 427 | + }); | |
| 428 | + expect(Option.isNone(missingActor)).toBe(true); | |
| 429 | + }), | |
| 430 | + ); | |
| 431 | + | |
| 274 | 432 | it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () => |
| 275 | 433 | Effect.gen(function* () { |
| 276 | 434 | const persistence = yield* makePersistence; |
| @@ -291,7 +449,6 @@ describe("control-plane-persistence-drizzle", () => { · 14 unmodified lines | ||
| 291 | 449 | expect(removed).toBe(true); |
| 292 | 450 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 293 | 451 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 294 | − expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 295 | 452 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 296 | 453 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 297 | 454 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); |
| @@ -318,7 +475,6 @@ describe("control-plane-persistence-drizzle", () => { · 20 unmodified lines | ||
| 318 | 475 | expect(removed).toBe(true); |
| 319 | 476 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 320 | 477 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 321 | − expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 322 | 478 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 323 | 479 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 324 | 480 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); |
| @@ -15,7 +15,11 @@ import { · 14 unmodified lines | ||
| 15 | 15 | createPoliciesRepo, |
| 16 | 16 | createSecretMaterialsRepo, |
| 17 | 17 | createSourceAuthSessionsRepo, |
| 18 | − createSourceCredentialBindingsRepo, | |
| 18 | + createSourceOauthClientsRepo, | |
| 19 | + createSourceRecipeDocumentsRepo, | |
| 20 | + createSourceRecipeOperationsRepo, | |
| 21 | + createSourceRecipeRevisionsRepo, | |
| 22 | + createSourceRecipesRepo, | |
| 19 | 23 | createSourcesRepo, |
| 20 | 24 | createToolArtifactsRepo, |
| 21 | 25 | createWorkspacesRepo, |
| @@ -29,6 +33,7 @@ import { · 7 unmodified lines | ||
| 29 | 33 | type SqlBackend, |
| 30 | 34 | type SqlRuntime, |
| 31 | 35 | } from "./sql-runtime"; |
| 36 | +import { runPostMigrationRepairs } from "./post-migrations"; | |
| 32 | 37 | |
| 33 | 38 | export { tableNames, type DrizzleTables } from "./schema"; |
| 34 | 39 | export { |
| @@ -52,9 +57,13 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 17 unmodified lines | ||
| 52 | 57 | organizationMemberships: createOrganizationMembershipsRepo(client, tables), |
| 53 | 58 | workspaces: createWorkspacesRepo(client, tables), |
| 54 | 59 | sources: createSourcesRepo(client, tables), |
| 60 | + sourceRecipes: createSourceRecipesRepo(client, tables), | |
| 61 | + sourceRecipeRevisions: createSourceRecipeRevisionsRepo(client, tables), | |
| 62 | + sourceRecipeDocuments: createSourceRecipeDocumentsRepo(client, tables), | |
| 63 | + sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables), | |
| 55 | 64 | credentials: createCredentialsRepo(client, tables), |
| 65 | + sourceOauthClients: createSourceOauthClientsRepo(client, tables), | |
| 56 | 66 | toolArtifacts: createToolArtifactsRepo(client, tables), |
| 57 | − sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables), | |
| 58 | 67 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 59 | 68 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| 60 | 69 | policies: createPoliciesRepo(client, tables), |
| @@ -127,14 +136,20 @@ export const createSqlControlPlanePersistence = ( · 66 unmodified lines | ||
| 127 | 136 | backend: runtime.backend, |
| 128 | 137 | db: runtime.db, |
| 129 | 138 | }); |
| 139 | + const rows = createRows(client); | |
| 130 | 140 | |
| 131 | 141 | return { |
| 132 | 142 | backend: runtime.backend, |
| 133 | 143 | db: runtime.db, |
| 134 | − rows: createRows(client), | |
| 144 | + rows, | |
| 135 | 145 | close: () => runtime.close(), |
| 136 | 146 | } satisfies SqlControlPlanePersistence; |
| 137 | 147 | }), |
| 148 | + Effect.flatMap((persistence) => | |
| 149 | + runPostMigrationRepairs(persistence.rows).pipe( | |
| 150 | + Effect.mapError(toBootstrapError), | |
| 151 | + Effect.map(() => persistence), | |
| 152 | + )), | |
| 138 | 153 | Effect.catchAll((error) => |
| 139 | 154 | closeRuntimeEffect(runtime).pipe( |
| 140 | 155 | Effect.zipRight(Effect.fail(error)), |
| @@ -0,0 +1,269 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + AccountIdSchema, | |
| 4 | + OrganizationIdSchema, | |
| 5 | + SourceIdSchema, | |
| 6 | + SourceRecipeIdSchema, | |
| 7 | + SourceRecipeRevisionIdSchema, | |
| 8 | + WorkspaceIdSchema, | |
| 9 | +} from "#schema"; | |
| 10 | +import * as Effect from "effect/Effect"; | |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import * as Scope from "effect/Scope"; | |
| 13 | +import { | |
| 14 | + buildSchema, | |
| 15 | + getIntrospectionQuery, | |
| 16 | + graphqlSync, | |
| 17 | +} from "graphql"; | |
| 18 | + | |
| 19 | +import { | |
| 20 | + createSqlControlPlanePersistence, | |
| 21 | + type SqlControlPlanePersistence, | |
| 22 | +} from "./index"; | |
| 23 | +import { runPostMigrationRepairs } from "./post-migrations"; | |
| 24 | + | |
| 25 | +const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | |
| 26 | + Effect.acquireRelease( | |
| 27 | + createSqlControlPlanePersistence({ | |
| 28 | + localDataDir: ":memory:", | |
| 29 | + }), | |
| 30 | + (persistence) => | |
| 31 | + Effect.tryPromise({ | |
| 32 | + try: () => persistence.close(), | |
| 33 | + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | |
| 34 | + }).pipe(Effect.orDie), | |
| 35 | + ); | |
| 36 | + | |
| 37 | +const seedMigratedSourceRecipe = (input: { | |
| 38 | + persistence: SqlControlPlanePersistence; | |
| 39 | + kind: "openapi" | "graphql"; | |
| 40 | + workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | |
| 41 | + sourceId: ReturnType<typeof SourceIdSchema.make>; | |
| 42 | + documentText: string; | |
| 43 | +}): Effect.Effect<{ | |
| 44 | + recipeRevisionId: ReturnType<typeof SourceRecipeRevisionIdSchema.make>; | |
| 45 | +}, unknown, never> => | |
| 46 | + Effect.gen(function* () { | |
| 47 | + const now = Date.now(); | |
| 48 | + const accountId = AccountIdSchema.make(`acc_${input.sourceId}`); | |
| 49 | + const organizationId = OrganizationIdSchema.make(`org_${input.sourceId}`); | |
| 50 | + const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); | |
| 51 | + const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`); | |
| 52 | + | |
| 53 | + yield* input.persistence.rows.organizations.insert({ | |
| 54 | + id: organizationId, | |
| 55 | + slug: `org-${input.sourceId}`, | |
| 56 | + name: `Org ${input.sourceId}`, | |
| 57 | + status: "active", | |
| 58 | + createdByAccountId: accountId, | |
| 59 | + createdAt: now, | |
| 60 | + updatedAt: now, | |
| 61 | + }); | |
| 62 | + yield* input.persistence.rows.workspaces.insert({ | |
| 63 | + id: input.workspaceId, | |
| 64 | + organizationId, | |
| 65 | + name: `Workspace ${input.sourceId}`, | |
| 66 | + createdByAccountId: accountId, | |
| 67 | + createdAt: now, | |
| 68 | + updatedAt: now, | |
| 69 | + }); | |
| 70 | + yield* input.persistence.rows.sourceRecipes.upsert({ | |
| 71 | + id: recipeId, | |
| 72 | + kind: input.kind === "openapi" ? "http_recipe" : "graphql_recipe", | |
| 73 | + importerKind: input.kind === "openapi" ? "openapi" : "graphql_introspection", | |
| 74 | + providerKey: input.kind === "openapi" ? "generic_http" : "generic_graphql", | |
| 75 | + name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo", | |
| 76 | + summary: null, | |
| 77 | + visibility: "workspace", | |
| 78 | + latestRevisionId: recipeRevisionId, | |
| 79 | + createdAt: now, | |
| 80 | + updatedAt: now, | |
| 81 | + }); | |
| 82 | + yield* input.persistence.rows.sourceRecipeRevisions.upsert({ | |
| 83 | + id: recipeRevisionId, | |
| 84 | + recipeId, | |
| 85 | + revisionNumber: 1, | |
| 86 | + sourceConfigJson: JSON.stringify( | |
| 87 | + input.kind === "openapi" | |
| 88 | + ? { | |
| 89 | + kind: "openapi", | |
| 90 | + endpoint: "https://api.example.com", | |
| 91 | + specUrl: "https://api.example.com/openapi.json", | |
| 92 | + defaultHeaders: null, | |
| 93 | + } | |
| 94 | + : { | |
| 95 | + kind: "graphql", | |
| 96 | + endpoint: "https://api.example.com/graphql", | |
| 97 | + defaultHeaders: null, | |
| 98 | + }, | |
| 99 | + ), | |
| 100 | + manifestJson: null, | |
| 101 | + manifestHash: null, | |
| 102 | + createdAt: now, | |
| 103 | + updatedAt: now, | |
| 104 | + }); | |
| 105 | + yield* input.persistence.rows.sources.insert({ | |
| 106 | + id: input.sourceId, | |
| 107 | + workspaceId: input.workspaceId, | |
| 108 | + recipeId, | |
| 109 | + recipeRevisionId, | |
| 110 | + name: input.kind === "openapi" ? "GitHub" : "GraphQL Demo", | |
| 111 | + kind: input.kind, | |
| 112 | + endpoint: | |
| 113 | + input.kind === "openapi" | |
| 114 | + ? "https://api.example.com" | |
| 115 | + : "https://api.example.com/graphql", | |
| 116 | + status: "connected", | |
| 117 | + enabled: true, | |
| 118 | + namespace: input.kind === "openapi" ? "github" : "graphql", | |
| 119 | + transport: null, | |
| 120 | + bindingConfigJson: null, | |
| 121 | + queryParamsJson: null, | |
| 122 | + headersJson: null, | |
| 123 | + specUrl: input.kind === "openapi" ? "https://api.example.com/openapi.json" : null, | |
| 124 | + defaultHeadersJson: null, | |
| 125 | + sourceHash: null, | |
| 126 | + sourceDocumentText: null, | |
| 127 | + lastError: null, | |
| 128 | + createdAt: now, | |
| 129 | + updatedAt: now, | |
| 130 | + }); | |
| 131 | + yield* input.persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 132 | + recipeRevisionId, | |
| 133 | + documents: [{ | |
| 134 | + id: `src_recipe_doc_${input.sourceId}`, | |
| 135 | + recipeRevisionId, | |
| 136 | + documentKind: input.kind === "openapi" ? "openapi" : "graphql_introspection", | |
| 137 | + documentKey: | |
| 138 | + input.kind === "openapi" | |
| 139 | + ? "https://api.example.com/openapi.json" | |
| 140 | + : "https://api.example.com/graphql", | |
| 141 | + contentText: input.documentText, | |
| 142 | + contentHash: `hash_${input.sourceId}`, | |
| 143 | + fetchedAt: now, | |
| 144 | + createdAt: now, | |
| 145 | + updatedAt: now, | |
| 146 | + }], | |
| 147 | + }); | |
| 148 | + | |
| 149 | + return { recipeRevisionId }; | |
| 150 | + }); | |
| 151 | + | |
| 152 | +describe("post-migrations", () => { | |
| 153 | + it.scoped("repairs migrated OpenAPI recipes from stored documents", () => | |
| 154 | + Effect.gen(function* () { | |
| 155 | + const persistence = yield* makePersistence; | |
| 156 | + const workspaceId = WorkspaceIdSchema.make("ws_post_migration_openapi"); | |
| 157 | + const sourceId = SourceIdSchema.make("src_post_migration_openapi"); | |
| 158 | + | |
| 159 | + const openApiDocument = JSON.stringify({ | |
| 160 | + openapi: "3.0.3", | |
| 161 | + info: { | |
| 162 | + title: "GitHub", | |
| 163 | + version: "1.0.0", | |
| 164 | + }, | |
| 165 | + paths: { | |
| 166 | + "/repos/{owner}/{repo}": { | |
| 167 | + get: { | |
| 168 | + operationId: "repos.getRepo", | |
| 169 | + parameters: [ | |
| 170 | + { | |
| 171 | + name: "owner", | |
| 172 | + in: "path", | |
| 173 | + required: true, | |
| 174 | + schema: { type: "string" }, | |
| 175 | + }, | |
| 176 | + { | |
| 177 | + name: "repo", | |
| 178 | + in: "path", | |
| 179 | + required: true, | |
| 180 | + schema: { type: "string" }, | |
| 181 | + }, | |
| 182 | + ], | |
| 183 | + responses: { | |
| 184 | + 200: { | |
| 185 | + description: "ok", | |
| 186 | + content: { | |
| 187 | + "application/json": { | |
| 188 | + schema: { | |
| 189 | + type: "object", | |
| 190 | + properties: { | |
| 191 | + full_name: { type: "string" }, | |
| 192 | + }, | |
| 193 | + required: ["full_name"], | |
| 194 | + }, | |
| 195 | + }, | |
| 196 | + }, | |
| 197 | + }, | |
| 198 | + }, | |
| 199 | + }, | |
| 200 | + }, | |
| 201 | + }, | |
| 202 | + }); | |
| 203 | + | |
| 204 | + const { recipeRevisionId } = yield* seedMigratedSourceRecipe({ | |
| 205 | + persistence, | |
| 206 | + kind: "openapi", | |
| 207 | + workspaceId, | |
| 208 | + sourceId, | |
| 209 | + documentText: openApiDocument, | |
| 210 | + }); | |
| 211 | + | |
| 212 | + yield* runPostMigrationRepairs(persistence.rows); | |
| 213 | + | |
| 214 | + const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId); | |
| 215 | + expect(Option.isSome(revision)).toBe(true); | |
| 216 | + expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); | |
| 217 | + expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length) | |
| 218 | + .toBeGreaterThan(0); | |
| 219 | + }), | |
| 220 | + ); | |
| 221 | + | |
| 222 | + it.scoped("repairs migrated GraphQL recipes from stored documents", () => | |
| 223 | + Effect.gen(function* () { | |
| 224 | + const persistence = yield* makePersistence; | |
| 225 | + const workspaceId = WorkspaceIdSchema.make("ws_post_migration_graphql"); | |
| 226 | + const sourceId = SourceIdSchema.make("src_post_migration_graphql"); | |
| 227 | + const schema = buildSchema(` | |
| 228 | + type Query { | |
| 229 | + viewer: User! | |
| 230 | + } | |
| 231 | + | |
| 232 | + type Mutation { | |
| 233 | + createIssue(title: String!): Issue! | |
| 234 | + } | |
| 235 | + | |
| 236 | + type User { | |
| 237 | + login: String! | |
| 238 | + } | |
| 239 | + | |
| 240 | + type Issue { | |
| 241 | + id: ID! | |
| 242 | + title: String! | |
| 243 | + } | |
| 244 | + `); | |
| 245 | + const graphqlDocument = JSON.stringify( | |
| 246 | + graphqlSync({ | |
| 247 | + schema, | |
| 248 | + source: getIntrospectionQuery(), | |
| 249 | + }), | |
| 250 | + ); | |
| 251 | + | |
| 252 | + const { recipeRevisionId } = yield* seedMigratedSourceRecipe({ | |
| 253 | + persistence, | |
| 254 | + kind: "graphql", | |
| 255 | + workspaceId, | |
| 256 | + sourceId, | |
| 257 | + documentText: graphqlDocument, | |
| 258 | + }); | |
| 259 | + | |
| 260 | + yield* runPostMigrationRepairs(persistence.rows); | |
| 261 | + | |
| 262 | + const revision = yield* persistence.rows.sourceRecipeRevisions.getById(recipeRevisionId); | |
| 263 | + expect(Option.isSome(revision)).toBe(true); | |
| 264 | + expect(revision.pipe(Option.getOrNull)?.manifestJson).not.toBeNull(); | |
| 265 | + expect((yield* persistence.rows.sourceRecipeOperations.listByRevisionId(recipeRevisionId)).length) | |
| 266 | + .toBeGreaterThan(0); | |
| 267 | + }), | |
| 268 | + ); | |
| 269 | +}); | |
| @@ -0,0 +1,321 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + buildOpenApiToolPresentation, | |
| 5 | + compileOpenApiToolDefinitions, | |
| 6 | + extractOpenApiManifest, | |
| 7 | +} from "@executor/codemode-openapi"; | |
| 8 | +import type { | |
| 9 | + StoredSourceRecipeDocumentRecord, | |
| 10 | + StoredSourceRecipeOperationRecord, | |
| 11 | + StoredSourceRecipeRevisionRecord, | |
| 12 | + StoredSourceRecord, | |
| 13 | +} from "#schema"; | |
| 14 | +import * as Effect from "effect/Effect"; | |
| 15 | + | |
| 16 | +import { | |
| 17 | + buildGraphqlToolPresentation, | |
| 18 | + compileGraphqlToolDefinitions, | |
| 19 | + extractGraphqlManifest, | |
| 20 | +} from "../runtime/graphql-tools"; | |
| 21 | +import type { SqlControlPlaneRows } from "./index"; | |
| 22 | + | |
| 23 | +const normalizeSearchText = (...parts: ReadonlyArray<string | null | undefined>): string => | |
| 24 | + parts | |
| 25 | + .flatMap((part) => (part ? [part.trim()] : [])) | |
| 26 | + .filter(Boolean) | |
| 27 | + .join(" ") | |
| 28 | + .toLowerCase(); | |
| 29 | + | |
| 30 | +const toOpenApiRecipeOperationRecord = (input: { | |
| 31 | + recipeRevisionId: StoredSourceRecipeRevisionRecord["id"]; | |
| 32 | + definition: ReturnType<typeof compileOpenApiToolDefinitions>[number]; | |
| 33 | + manifest: Parameters<typeof buildOpenApiToolPresentation>[0]["manifest"]; | |
| 34 | + now: number; | |
| 35 | +}): StoredSourceRecipeOperationRecord => { | |
| 36 | + const presentation = buildOpenApiToolPresentation({ | |
| 37 | + manifest: input.manifest, | |
| 38 | + definition: input.definition, | |
| 39 | + }); | |
| 40 | + const method = input.definition.method.toUpperCase(); | |
| 41 | + | |
| 42 | + return { | |
| 43 | + id: `src_recipe_op_${randomUUID()}`, | |
| 44 | + recipeRevisionId: input.recipeRevisionId, | |
| 45 | + operationKey: input.definition.toolId, | |
| 46 | + transportKind: "http", | |
| 47 | + toolId: input.definition.toolId, | |
| 48 | + title: input.definition.name, | |
| 49 | + description: input.definition.description, | |
| 50 | + operationKind: | |
| 51 | + method === "GET" || method === "HEAD" | |
| 52 | + ? "read" | |
| 53 | + : method === "DELETE" | |
| 54 | + ? "delete" | |
| 55 | + : "write", | |
| 56 | + searchText: normalizeSearchText( | |
| 57 | + input.definition.toolId, | |
| 58 | + input.definition.name, | |
| 59 | + input.definition.description, | |
| 60 | + input.definition.rawToolId, | |
| 61 | + input.definition.operationId ?? undefined, | |
| 62 | + input.definition.method, | |
| 63 | + input.definition.path, | |
| 64 | + input.definition.group, | |
| 65 | + input.definition.leaf, | |
| 66 | + input.definition.tags.join(" "), | |
| 67 | + ), | |
| 68 | + inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 69 | + outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 70 | + providerKind: "openapi", | |
| 71 | + providerDataJson: presentation.providerDataJson, | |
| 72 | + mcpToolName: null, | |
| 73 | + openApiMethod: input.definition.method, | |
| 74 | + openApiPathTemplate: input.definition.path, | |
| 75 | + openApiOperationHash: input.definition.operationHash, | |
| 76 | + openApiRawToolId: input.definition.rawToolId, | |
| 77 | + openApiOperationId: input.definition.operationId ?? null, | |
| 78 | + openApiTagsJson: JSON.stringify(input.definition.tags), | |
| 79 | + openApiRequestBodyRequired: input.definition.invocation.requestBody?.required ?? null, | |
| 80 | + graphqlOperationType: null, | |
| 81 | + graphqlOperationName: null, | |
| 82 | + createdAt: input.now, | |
| 83 | + updatedAt: input.now, | |
| 84 | + }; | |
| 85 | +}; | |
| 86 | + | |
| 87 | +const toGraphqlRecipeOperationRecord = (input: { | |
| 88 | + recipeRevisionId: StoredSourceRecipeRevisionRecord["id"]; | |
| 89 | + definition: ReturnType<typeof compileGraphqlToolDefinitions>[number]; | |
| 90 | + manifest: Parameters<typeof buildGraphqlToolPresentation>[0]["manifest"]; | |
| 91 | + now: number; | |
| 92 | +}): StoredSourceRecipeOperationRecord => { | |
| 93 | + const presentation = buildGraphqlToolPresentation({ | |
| 94 | + manifest: input.manifest, | |
| 95 | + definition: input.definition, | |
| 96 | + }); | |
| 97 | + | |
| 98 | + return { | |
| 99 | + id: `src_recipe_op_${randomUUID()}`, | |
| 100 | + recipeRevisionId: input.recipeRevisionId, | |
| 101 | + operationKey: input.definition.toolId, | |
| 102 | + transportKind: "graphql", | |
| 103 | + toolId: input.definition.toolId, | |
| 104 | + title: input.definition.name, | |
| 105 | + description: input.definition.description, | |
| 106 | + operationKind: | |
| 107 | + input.definition.operationType === "query" | |
| 108 | + ? "read" | |
| 109 | + : input.definition.operationType === "mutation" | |
| 110 | + ? "write" | |
| 111 | + : "unknown", | |
| 112 | + searchText: normalizeSearchText( | |
| 113 | + input.definition.toolId, | |
| 114 | + input.definition.name, | |
| 115 | + input.definition.description, | |
| 116 | + input.definition.rawToolId, | |
| 117 | + input.definition.group, | |
| 118 | + input.definition.leaf, | |
| 119 | + input.definition.fieldName, | |
| 120 | + input.definition.operationType, | |
| 121 | + input.definition.operationName, | |
| 122 | + input.definition.searchTerms.join(" "), | |
| 123 | + ), | |
| 124 | + inputSchemaJson: presentation.inputSchemaJson ?? null, | |
| 125 | + outputSchemaJson: presentation.outputSchemaJson ?? null, | |
| 126 | + providerKind: "graphql", | |
| 127 | + providerDataJson: presentation.providerDataJson, | |
| 128 | + mcpToolName: null, | |
| 129 | + openApiMethod: null, | |
| 130 | + openApiPathTemplate: null, | |
| 131 | + openApiOperationHash: null, | |
| 132 | + openApiRawToolId: null, | |
| 133 | + openApiOperationId: null, | |
| 134 | + openApiTagsJson: null, | |
| 135 | + openApiRequestBodyRequired: null, | |
| 136 | + graphqlOperationType: input.definition.operationType, | |
| 137 | + graphqlOperationName: input.definition.operationName, | |
| 138 | + createdAt: input.now, | |
| 139 | + updatedAt: input.now, | |
| 140 | + }; | |
| 141 | +}; | |
| 142 | + | |
| 143 | +const primaryRecipeDocument = (input: { | |
| 144 | + sourceRecord: StoredSourceRecord; | |
| 145 | + documents: readonly StoredSourceRecipeDocumentRecord[]; | |
| 146 | +}): StoredSourceRecipeDocumentRecord | null => { | |
| 147 | + const documentKind = | |
| 148 | + input.sourceRecord.kind === "openapi" | |
| 149 | + ? "openapi" | |
| 150 | + : input.sourceRecord.kind === "graphql" | |
| 151 | + ? "graphql_introspection" | |
| 152 | + : null; | |
| 153 | + | |
| 154 | + if (documentKind === null) { | |
| 155 | + return null; | |
| 156 | + } | |
| 157 | + | |
| 158 | + return input.documents.find((document) => document.documentKind === documentKind) ?? null; | |
| 159 | +}; | |
| 160 | + | |
| 161 | +const repairOpenApiRecipeRevision = (input: { | |
| 162 | + rows: SqlControlPlaneRows; | |
| 163 | + sourceRecord: StoredSourceRecord; | |
| 164 | + revision: StoredSourceRecipeRevisionRecord; | |
| 165 | + document: StoredSourceRecipeDocumentRecord; | |
| 166 | +}): Effect.Effect<void, Error, never> => | |
| 167 | + Effect.gen(function* () { | |
| 168 | + const manifest = yield* extractOpenApiManifest( | |
| 169 | + input.sourceRecord.name, | |
| 170 | + input.document.contentText, | |
| 171 | + ); | |
| 172 | + const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt); | |
| 173 | + const definitions = compileOpenApiToolDefinitions(manifest); | |
| 174 | + | |
| 175 | + yield* input.rows.sourceRecipeRevisions.update(input.revision.id, { | |
| 176 | + manifestJson: JSON.stringify(manifest), | |
| 177 | + manifestHash: manifest.sourceHash, | |
| 178 | + updatedAt: now, | |
| 179 | + }); | |
| 180 | + yield* input.rows.sourceRecipeOperations.replaceForRevision({ | |
| 181 | + recipeRevisionId: input.revision.id, | |
| 182 | + operations: definitions.map((definition) => | |
| 183 | + toOpenApiRecipeOperationRecord({ | |
| 184 | + recipeRevisionId: input.revision.id, | |
| 185 | + definition, | |
| 186 | + manifest, | |
| 187 | + now, | |
| 188 | + }) | |
| 189 | + ), | |
| 190 | + }); | |
| 191 | + | |
| 192 | + if (input.sourceRecord.sourceHash !== manifest.sourceHash) { | |
| 193 | + yield* input.rows.sources.update( | |
| 194 | + input.sourceRecord.workspaceId, | |
| 195 | + input.sourceRecord.id, | |
| 196 | + { | |
| 197 | + sourceHash: manifest.sourceHash, | |
| 198 | + updatedAt: now, | |
| 199 | + }, | |
| 200 | + ); | |
| 201 | + } | |
| 202 | + }); | |
| 203 | + | |
| 204 | +const repairGraphqlRecipeRevision = (input: { | |
| 205 | + rows: SqlControlPlaneRows; | |
| 206 | + sourceRecord: StoredSourceRecord; | |
| 207 | + revision: StoredSourceRecipeRevisionRecord; | |
| 208 | + document: StoredSourceRecipeDocumentRecord; | |
| 209 | +}): Effect.Effect<void, Error, never> => | |
| 210 | + Effect.gen(function* () { | |
| 211 | + const manifest = yield* extractGraphqlManifest( | |
| 212 | + input.sourceRecord.name, | |
| 213 | + input.document.contentText, | |
| 214 | + ); | |
| 215 | + const now = Math.max(input.revision.updatedAt, input.sourceRecord.updatedAt); | |
| 216 | + const definitions = compileGraphqlToolDefinitions(manifest); | |
| 217 | + | |
| 218 | + yield* input.rows.sourceRecipeRevisions.update(input.revision.id, { | |
| 219 | + manifestJson: JSON.stringify(manifest), | |
| 220 | + manifestHash: manifest.sourceHash, | |
| 221 | + updatedAt: now, | |
| 222 | + }); | |
| 223 | + yield* input.rows.sourceRecipeOperations.replaceForRevision({ | |
| 224 | + recipeRevisionId: input.revision.id, | |
| 225 | + operations: definitions.map((definition) => | |
| 226 | + toGraphqlRecipeOperationRecord({ | |
| 227 | + recipeRevisionId: input.revision.id, | |
| 228 | + definition, | |
| 229 | + manifest, | |
| 230 | + now, | |
| 231 | + }) | |
| 232 | + ), | |
| 233 | + }); | |
| 234 | + | |
| 235 | + if (input.sourceRecord.sourceHash !== manifest.sourceHash) { | |
| 236 | + yield* input.rows.sources.update( | |
| 237 | + input.sourceRecord.workspaceId, | |
| 238 | + input.sourceRecord.id, | |
| 239 | + { | |
| 240 | + sourceHash: manifest.sourceHash, | |
| 241 | + updatedAt: now, | |
| 242 | + }, | |
| 243 | + ); | |
| 244 | + } | |
| 245 | + }); | |
| 246 | + | |
| 247 | +// Repair pre-recipe OpenAPI/GraphQL sources that were migrated with raw documents | |
| 248 | +// but without the canonical manifest/operation rows required by the new runtime. | |
| 249 | +export const runPostMigrationRepairs = ( | |
| 250 | + rows: SqlControlPlaneRows, | |
| 251 | +): Effect.Effect<void, Error, never> => | |
| 252 | + Effect.gen(function* () { | |
| 253 | + const sourceRecords = yield* rows.sources.listAll(); | |
| 254 | + if (sourceRecords.length === 0) { | |
| 255 | + return; | |
| 256 | + } | |
| 257 | + | |
| 258 | + const revisionIds = [...new Set(sourceRecords.map((sourceRecord) => sourceRecord.recipeRevisionId))]; | |
| 259 | + const revisions = yield* rows.sourceRecipeRevisions.listByIds(revisionIds); | |
| 260 | + const documents = yield* rows.sourceRecipeDocuments.listByRevisionIds(revisionIds); | |
| 261 | + const operations = yield* rows.sourceRecipeOperations.listByRevisionIds(revisionIds); | |
| 262 | + | |
| 263 | + const revisionById = new Map(revisions.map((revision) => [revision.id, revision])); | |
| 264 | + const documentsByRevisionId = new Map<string, StoredSourceRecipeDocumentRecord[]>(); | |
| 265 | + for (const document of documents) { | |
| 266 | + const existing = documentsByRevisionId.get(document.recipeRevisionId) ?? []; | |
| 267 | + existing.push(document); | |
| 268 | + documentsByRevisionId.set(document.recipeRevisionId, existing); | |
| 269 | + } | |
| 270 | + | |
| 271 | + const operationCountsByRevisionId = new Map<string, number>(); | |
| 272 | + for (const operation of operations) { | |
| 273 | + operationCountsByRevisionId.set( | |
| 274 | + operation.recipeRevisionId, | |
| 275 | + (operationCountsByRevisionId.get(operation.recipeRevisionId) ?? 0) + 1, | |
| 276 | + ); | |
| 277 | + } | |
| 278 | + | |
| 279 | + yield* Effect.forEach(sourceRecords, (sourceRecord) => | |
| 280 | + Effect.gen(function* () { | |
| 281 | + if (sourceRecord.kind !== "openapi" && sourceRecord.kind !== "graphql") { | |
| 282 | + return; | |
| 283 | + } | |
| 284 | + | |
| 285 | + const revision = revisionById.get(sourceRecord.recipeRevisionId); | |
| 286 | + if (!revision) { | |
| 287 | + return; | |
| 288 | + } | |
| 289 | + | |
| 290 | + const existingOperationCount = operationCountsByRevisionId.get(revision.id) ?? 0; | |
| 291 | + if (revision.manifestJson !== null && existingOperationCount > 0) { | |
| 292 | + return; | |
| 293 | + } | |
| 294 | + | |
| 295 | + const sourceDocuments = documentsByRevisionId.get(revision.id) ?? []; | |
| 296 | + const document = primaryRecipeDocument({ | |
| 297 | + sourceRecord, | |
| 298 | + documents: sourceDocuments, | |
| 299 | + }); | |
| 300 | + if (!document) { | |
| 301 | + return; | |
| 302 | + } | |
| 303 | + | |
| 304 | + if (sourceRecord.kind === "openapi") { | |
| 305 | + yield* repairOpenApiRecipeRevision({ | |
| 306 | + rows, | |
| 307 | + sourceRecord, | |
| 308 | + revision, | |
| 309 | + document, | |
| 310 | + }); | |
| 311 | + return; | |
| 312 | + } | |
| 313 | + | |
| 314 | + yield* repairGraphqlRecipeRevision({ | |
| 315 | + rows, | |
| 316 | + sourceRecord, | |
| 317 | + revision, | |
| 318 | + document, | |
| 319 | + }); | |
| 320 | + }), { discard: true }); | |
| 321 | + }); | |
| @@ -4,14 +4,26 @@ import { · 3 unmodified lines | ||
| 4 | 4 | } from "#schema"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | import { Schema } from "effect"; |
| 7 | −import { asc, eq } from "drizzle-orm"; | |
| 7 | +import { and, asc, eq, inArray, isNull, or } from "drizzle-orm"; | |
| 8 | 8 | |
| 9 | 9 | import type { DrizzleClient } from "../client"; |
| 10 | 10 | import type { DrizzleTables } from "../schema"; |
| 11 | −import { firstOption, withoutCreatedAt } from "./shared"; | |
| 11 | +import { firstOption } from "./shared"; | |
| 12 | 12 | |
| 13 | 13 | const decodeCredential = Schema.decodeUnknownSync(CredentialSchema); |
| 14 | 14 | |
| 15 | +const credentialUpdateSet = (credential: Credential) => { | |
| 16 | + const { | |
| 17 | + id: _id, | |
| 18 | + workspaceId: _workspaceId, | |
| 19 | + sourceId: _sourceId, | |
| 20 | + actorAccountId: _actorAccountId, | |
| 21 | + createdAt: _createdAt, | |
| 22 | + ...patch | |
| 23 | + } = credential; | |
| 24 | + return patch; | |
| 25 | +}; | |
| 26 | + | |
| 15 | 27 | export const createCredentialsRepo = ( |
| 16 | 28 | client: DrizzleClient, |
| 17 | 29 | tables: DrizzleTables, |
| @@ -30,12 +42,78 @@ export const createCredentialsRepo = ( · 12 unmodified lines | ||
| 30 | 42 | return rows.map((row) => decodeCredential(row)); |
| 31 | 43 | }), |
| 32 | 44 | |
| 33 | − getById: (id: Credential["id"]) => | |
| 34 | − client.use("rows.credentials.get_by_id", async (db) => { | |
| 45 | + listByWorkspaceAndSourceId: (input: { | |
| 46 | + workspaceId: Credential["workspaceId"]; | |
| 47 | + sourceId: Credential["sourceId"]; | |
| 48 | + }) => | |
| 49 | + client.use("rows.credentials.list_by_workspace_source", async (db) => { | |
| 35 | 50 | const rows = await db |
| 36 | 51 | .select() |
| 37 | 52 | .from(tables.credentialsTable) |
| 38 | − .where(eq(tables.credentialsTable.id, id)) | |
| 53 | + .where( | |
| 54 | + and( | |
| 55 | + eq(tables.credentialsTable.workspaceId, input.workspaceId), | |
| 56 | + eq(tables.credentialsTable.sourceId, input.sourceId), | |
| 57 | + ), | |
| 58 | + ) | |
| 59 | + .orderBy( | |
| 60 | + asc(tables.credentialsTable.updatedAt), | |
| 61 | + asc(tables.credentialsTable.id), | |
| 62 | + ); | |
| 63 | + | |
| 64 | + return rows.map((row) => decodeCredential(row)); | |
| 65 | + }), | |
| 66 | + | |
| 67 | + listByWorkspaceSourceAndActor: (input: { | |
| 68 | + workspaceId: Credential["workspaceId"]; | |
| 69 | + sourceId: Credential["sourceId"]; | |
| 70 | + actorAccountId: Credential["actorAccountId"]; | |
| 71 | + }) => | |
| 72 | + client.use("rows.credentials.list_by_workspace_source_actor", async (db) => { | |
| 73 | + const rows = await db | |
| 74 | + .select() | |
| 75 | + .from(tables.credentialsTable) | |
| 76 | + .where( | |
| 77 | + and( | |
| 78 | + eq(tables.credentialsTable.workspaceId, input.workspaceId), | |
| 79 | + eq(tables.credentialsTable.sourceId, input.sourceId), | |
| 80 | + or( | |
| 81 | + input.actorAccountId === null | |
| 82 | + ? isNull(tables.credentialsTable.actorAccountId) | |
| 83 | + : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | |
| 84 | + input.actorAccountId === null | |
| 85 | + ? undefined | |
| 86 | + : isNull(tables.credentialsTable.actorAccountId), | |
| 87 | + ), | |
| 88 | + ), | |
| 89 | + ) | |
| 90 | + .orderBy( | |
| 91 | + asc(tables.credentialsTable.actorAccountId), | |
| 92 | + asc(tables.credentialsTable.updatedAt), | |
| 93 | + asc(tables.credentialsTable.id), | |
| 94 | + ); | |
| 95 | + | |
| 96 | + return rows.map((row) => decodeCredential(row)); | |
| 97 | + }), | |
| 98 | + | |
| 99 | + getByWorkspaceSourceAndActor: (input: { | |
| 100 | + workspaceId: Credential["workspaceId"]; | |
| 101 | + sourceId: Credential["sourceId"]; | |
| 102 | + actorAccountId: Credential["actorAccountId"]; | |
| 103 | + }) => | |
| 104 | + client.use("rows.credentials.get_by_workspace_source_actor", async (db) => { | |
| 105 | + const rows = await db | |
| 106 | + .select() | |
| 107 | + .from(tables.credentialsTable) | |
| 108 | + .where( | |
| 109 | + and( | |
| 110 | + eq(tables.credentialsTable.workspaceId, input.workspaceId), | |
| 111 | + eq(tables.credentialsTable.sourceId, input.sourceId), | |
| 112 | + input.actorAccountId === null | |
| 113 | + ? isNull(tables.credentialsTable.actorAccountId) | |
| 114 | + : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | |
| 115 | + ), | |
| 116 | + ) | |
| 39 | 117 | .limit(1); |
| 40 | 118 | |
| 41 | 119 | const row = firstOption(rows); |
| @@ -46,24 +124,96 @@ export const createCredentialsRepo = ( · 4 unmodified lines | ||
| 46 | 124 | |
| 47 | 125 | upsert: (credential: Credential) => |
| 48 | 126 | client.use("rows.credentials.upsert", async (db) => { |
| 127 | + if (credential.actorAccountId === null) { | |
| 128 | + const existingRows = await db | |
| 129 | + .select({ | |
| 130 | + id: tables.credentialsTable.id, | |
| 131 | + }) | |
| 132 | + .from(tables.credentialsTable) | |
| 133 | + .where( | |
| 134 | + and( | |
| 135 | + eq(tables.credentialsTable.workspaceId, credential.workspaceId), | |
| 136 | + eq(tables.credentialsTable.sourceId, credential.sourceId), | |
| 137 | + isNull(tables.credentialsTable.actorAccountId), | |
| 138 | + ), | |
| 139 | + ) | |
| 140 | + .orderBy( | |
| 141 | + asc(tables.credentialsTable.updatedAt), | |
| 142 | + asc(tables.credentialsTable.id), | |
| 143 | + ); | |
| 144 | + | |
| 145 | + const existing = firstOption(existingRows); | |
| 146 | + if (Option.isSome(existing)) { | |
| 147 | + await db | |
| 148 | + .update(tables.credentialsTable) | |
| 149 | + .set(credentialUpdateSet(credential)) | |
| 150 | + .where(eq(tables.credentialsTable.id, existing.value.id)); | |
| 151 | + | |
| 152 | + const duplicateIds = existingRows | |
| 153 | + .slice(1) | |
| 154 | + .map((row) => row.id); | |
| 155 | + if (duplicateIds.length > 0) { | |
| 156 | + await db | |
| 157 | + .delete(tables.credentialsTable) | |
| 158 | + .where(inArray(tables.credentialsTable.id, duplicateIds)); | |
| 159 | + } | |
| 160 | + | |
| 161 | + return; | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 49 | 165 | await db |
| 50 | 166 | .insert(tables.credentialsTable) |
| 51 | 167 | .values(credential) |
| 52 | 168 | .onConflictDoUpdate({ |
| 53 | − target: [tables.credentialsTable.id], | |
| 169 | + target: [ | |
| 170 | + tables.credentialsTable.workspaceId, | |
| 171 | + tables.credentialsTable.sourceId, | |
| 172 | + tables.credentialsTable.actorAccountId, | |
| 173 | + ], | |
| 54 | 174 | set: { |
| 55 | − ...withoutCreatedAt(credential), | |
| 175 | + ...credentialUpdateSet(credential), | |
| 56 | 176 | }, |
| 57 | 177 | }); |
| 58 | 178 | }), |
| 59 | 179 | |
| 60 | − removeById: (id: Credential["id"]) => | |
| 61 | − client.use("rows.credentials.remove", async (db) => { | |
| 180 | + removeByWorkspaceSourceAndActor: (input: { | |
| 181 | + workspaceId: Credential["workspaceId"]; | |
| 182 | + sourceId: Credential["sourceId"]; | |
| 183 | + actorAccountId: Credential["actorAccountId"]; | |
| 184 | + }) => | |
| 185 | + client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => { | |
| 62 | 186 | const deleted = await db |
| 63 | 187 | .delete(tables.credentialsTable) |
| 64 | − .where(eq(tables.credentialsTable.id, id)) | |
| 188 | + .where( | |
| 189 | + and( | |
| 190 | + eq(tables.credentialsTable.workspaceId, input.workspaceId), | |
| 191 | + eq(tables.credentialsTable.sourceId, input.sourceId), | |
| 192 | + input.actorAccountId === null | |
| 193 | + ? isNull(tables.credentialsTable.actorAccountId) | |
| 194 | + : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | |
| 195 | + ), | |
| 196 | + ) | |
| 65 | 197 | .returning(); |
| 66 | 198 | |
| 67 | 199 | return deleted.length > 0; |
| 68 | 200 | }), |
| 201 | + | |
| 202 | + removeByWorkspaceAndSourceId: (input: { | |
| 203 | + workspaceId: Credential["workspaceId"]; | |
| 204 | + sourceId: Credential["sourceId"]; | |
| 205 | + }) => | |
| 206 | + client.use("rows.credentials.remove_by_workspace_source", async (db) => { | |
| 207 | + const deleted = await db | |
| 208 | + .delete(tables.credentialsTable) | |
| 209 | + .where( | |
| 210 | + and( | |
| 211 | + eq(tables.credentialsTable.workspaceId, input.workspaceId), | |
| 212 | + eq(tables.credentialsTable.sourceId, input.sourceId), | |
| 213 | + ), | |
| 214 | + ) | |
| 215 | + .returning(); | |
| 216 | + | |
| 217 | + return deleted.length; | |
| 218 | + }), | |
| 69 | 219 | }); |
| @@ -3,9 +3,13 @@ export { createOrganizationsRepo } from "./organizations-repo"; · 2 unmodified lines | ||
| 3 | 3 | export { createOrganizationMembershipsRepo } from "./organization-memberships-repo"; |
| 4 | 4 | export { createWorkspacesRepo } from "./workspaces-repo"; |
| 5 | 5 | export { createSourcesRepo } from "./sources-repo"; |
| 6 | +export { createSourceRecipesRepo } from "./source-recipes-repo"; | |
| 7 | +export { createSourceRecipeRevisionsRepo } from "./source-recipe-revisions-repo"; | |
| 8 | +export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo"; | |
| 9 | +export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo"; | |
| 6 | 10 | export { createCredentialsRepo } from "./credentials-repo"; |
| 11 | +export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; | |
| 7 | 12 | export { createToolArtifactsRepo } from "./tool-artifacts-repo"; |
| 8 | −export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo"; | |
| 9 | 13 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 10 | 14 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| 11 | 15 | export { createPoliciesRepo } from "./policies-repo"; |
| @@ -6,7 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | } from "#schema"; |
| 7 | 7 | import * as Option from "effect/Option"; |
| 8 | 8 | import { Schema } from "effect"; |
| 9 | −import { asc, eq, inArray } from "drizzle-orm"; | |
| 9 | +import { and, asc, eq, inArray, or } from "drizzle-orm"; | |
| 10 | 10 | |
| 11 | 11 | import type { DrizzleClient } from "../client"; |
| 12 | 12 | import type { DrizzleTables } from "../schema"; |
| @@ -136,6 +136,13 @@ export const createOrganizationsRepo = ( · 123 unmodified lines | ||
| 136 | 136 | .select({ id: tables.executionsTable.id }) |
| 137 | 137 | .from(tables.executionsTable) |
| 138 | 138 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| 139 | + const sourceRows = await tx | |
| 140 | + .select({ | |
| 141 | + sourceId: tables.sourcesTable.sourceId, | |
| 142 | + recipeId: tables.sourcesTable.recipeId, | |
| 143 | + }) | |
| 144 | + .from(tables.sourcesTable) | |
| 145 | + .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | |
| 139 | 146 | const credentials = await tx |
| 140 | 147 | .select({ |
| 141 | 148 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| @@ -147,6 +154,28 @@ export const createOrganizationsRepo = ( · 5 unmodified lines | ||
| 147 | 154 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); |
| 148 | 155 | |
| 149 | 156 | const executionIds = executionRows.map((execution) => execution.id); |
| 157 | + const sourceIds = sourceRows.map((source) => source.sourceId); | |
| 158 | + const recipeIds = sourceRows.map((source) => source.recipeId); | |
| 159 | + const recipeRevisionRows = recipeIds.length > 0 | |
| 160 | + ? await tx | |
| 161 | + .select({ id: tables.sourceRecipeRevisionsTable.id }) | |
| 162 | + .from(tables.sourceRecipeRevisionsTable) | |
| 163 | + .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)) | |
| 164 | + : []; | |
| 165 | + const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id); | |
| 166 | + const toolArtifactPaths = sourceIds.length > 0 | |
| 167 | + ? ( | |
| 168 | + await tx | |
| 169 | + .select({ path: tables.toolArtifactsTable.path }) | |
| 170 | + .from(tables.toolArtifactsTable) | |
| 171 | + .where( | |
| 172 | + and( | |
| 173 | + inArray(tables.toolArtifactsTable.workspaceId, workspaceIds), | |
| 174 | + inArray(tables.toolArtifactsTable.sourceId, sourceIds), | |
| 175 | + ), | |
| 176 | + ) | |
| 177 | + ).map((row) => row.path) | |
| 178 | + : []; | |
| 150 | 179 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 151 | 180 | |
| 152 | 181 | if (executionIds.length > 0) { |
| @@ -157,6 +186,39 @@ export const createOrganizationsRepo = ( · 4 unmodified lines | ||
| 157 | 186 | ); |
| 158 | 187 | } |
| 159 | 188 | |
| 189 | + if (toolArtifactPaths.length > 0) { | |
| 190 | + await tx | |
| 191 | + .delete(tables.toolArtifactParametersTable) | |
| 192 | + .where( | |
| 193 | + and( | |
| 194 | + inArray(tables.toolArtifactParametersTable.workspaceId, workspaceIds), | |
| 195 | + or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))), | |
| 196 | + ), | |
| 197 | + ); | |
| 198 | + | |
| 199 | + await tx | |
| 200 | + .delete(tables.toolArtifactRequestBodyContentTypesTable) | |
| 201 | + .where( | |
| 202 | + and( | |
| 203 | + inArray(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceIds), | |
| 204 | + or( | |
| 205 | + ...toolArtifactPaths.map((path) => | |
| 206 | + eq(tables.toolArtifactRequestBodyContentTypesTable.path, path) | |
| 207 | + ), | |
| 208 | + ), | |
| 209 | + ), | |
| 210 | + ); | |
| 211 | + | |
| 212 | + await tx | |
| 213 | + .delete(tables.toolArtifactRefHintKeysTable) | |
| 214 | + .where( | |
| 215 | + and( | |
| 216 | + inArray(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceIds), | |
| 217 | + or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))), | |
| 218 | + ), | |
| 219 | + ); | |
| 220 | + } | |
| 221 | + | |
| 160 | 222 | await tx |
| 161 | 223 | .delete(tables.executionsTable) |
| 162 | 224 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| @@ -166,17 +228,41 @@ export const createOrganizationsRepo = ( · 3 unmodified lines | ||
| 166 | 228 | .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds)); |
| 167 | 229 | |
| 168 | 230 | await tx |
| 169 | − .delete(tables.sourceCredentialBindingsTable) | |
| 170 | − .where(inArray(tables.sourceCredentialBindingsTable.workspaceId, workspaceIds)); | |
| 231 | + .delete(tables.workspaceSourceOauthClientsTable) | |
| 232 | + .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds)); | |
| 171 | 233 | |
| 172 | 234 | await tx |
| 173 | 235 | .delete(tables.credentialsTable) |
| 174 | 236 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); |
| 175 | 237 | |
| 238 | + await tx | |
| 239 | + .delete(tables.toolArtifactsTable) | |
| 240 | + .where(inArray(tables.toolArtifactsTable.workspaceId, workspaceIds)); | |
| 241 | + | |
| 176 | 242 | await tx |
| 177 | 243 | .delete(tables.sourcesTable) |
| 178 | 244 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| 179 | 245 | |
| 246 | + if (recipeRevisionIds.length > 0) { | |
| 247 | + await tx | |
| 248 | + .delete(tables.sourceRecipeDocumentsTable) | |
| 249 | + .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds)); | |
| 250 | + | |
| 251 | + await tx | |
| 252 | + .delete(tables.sourceRecipeOperationsTable) | |
| 253 | + .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds)); | |
| 254 | + } | |
| 255 | + | |
| 256 | + if (recipeIds.length > 0) { | |
| 257 | + await tx | |
| 258 | + .delete(tables.sourceRecipeRevisionsTable) | |
| 259 | + .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)); | |
| 260 | + | |
| 261 | + await tx | |
| 262 | + .delete(tables.sourceRecipesTable) | |
| 263 | + .where(inArray(tables.sourceRecipesTable.id, recipeIds)); | |
| 264 | + } | |
| 265 | + | |
| 180 | 266 | await tx |
| 181 | 267 | .delete(tables.policiesTable) |
| 182 | 268 | .where(inArray(tables.policiesTable.workspaceId, workspaceIds)); |
| @@ -105,19 +105,15 @@ export const createSecretMaterialsRepo = ( · 104 unmodified lines | ||
| 105 | 105 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| 106 | 106 | refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, |
| 107 | 107 | refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, |
| 108 | − sourceId: tables.sourceCredentialBindingsTable.sourceId, | |
| 108 | + sourceId: tables.credentialsTable.sourceId, | |
| 109 | 109 | sourceName: tables.sourcesTable.name, |
| 110 | 110 | }) |
| 111 | 111 | .from(tables.credentialsTable) |
| 112 | − .innerJoin( | |
| 113 | − tables.sourceCredentialBindingsTable, | |
| 114 | − eq(tables.credentialsTable.id, tables.sourceCredentialBindingsTable.credentialId), | |
| 115 | − ) | |
| 116 | 112 | .innerJoin( |
| 117 | 113 | tables.sourcesTable, |
| 118 | 114 | and( |
| 119 | − eq(tables.sourceCredentialBindingsTable.workspaceId, tables.sourcesTable.workspaceId), | |
| 120 | − eq(tables.sourceCredentialBindingsTable.sourceId, tables.sourcesTable.sourceId), | |
| 115 | + eq(tables.credentialsTable.workspaceId, tables.sourcesTable.workspaceId), | |
| 116 | + eq(tables.credentialsTable.sourceId, tables.sourcesTable.sourceId), | |
| 121 | 117 | ), |
| 122 | 118 | ) |
| 123 | 119 | .where( |
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | } from "#schema"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | import { Schema } from "effect"; |
| 7 | −import { and, asc, eq } from "drizzle-orm"; | |
| 7 | +import { and, asc, eq, isNull } from "drizzle-orm"; | |
| 8 | 8 | |
| 9 | 9 | import type { DrizzleClient } from "../client"; |
| 10 | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -60,29 +60,39 @@ export const createSourceAuthSessionsRepo = ( · 49 unmodified lines | ||
| 60 | 60 | : Option.none<SourceAuthSession>(); |
| 61 | 61 | }), |
| 62 | 62 | |
| 63 | − getPendingByWorkspaceAndSourceId: ( | |
| 64 | − workspaceId: SourceAuthSession["workspaceId"], | |
| 65 | − sourceId: SourceAuthSession["sourceId"], | |
| 66 | − ) => | |
| 67 | − client.use("rows.source_auth_sessions.get_pending_by_workspace_and_source_id", async (db) => { | |
| 68 | − const rows = await db | |
| 69 | − .select() | |
| 70 | − .from(tables.sourceAuthSessionsTable) | |
| 71 | − .where( | |
| 72 | − and( | |
| 73 | − eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId), | |
| 74 | − eq(tables.sourceAuthSessionsTable.sourceId, sourceId), | |
| 75 | − eq(tables.sourceAuthSessionsTable.status, "pending"), | |
| 76 | − ), | |
| 77 | − ) | |
| 78 | − .orderBy(asc(tables.sourceAuthSessionsTable.updatedAt), asc(tables.sourceAuthSessionsTable.id)) | |
| 79 | − .limit(1); | |
| 80 | − | |
| 81 | − const row = firstOption(rows); | |
| 82 | − return Option.isSome(row) | |
| 83 | − ? Option.some(decodeSourceAuthSession(row.value)) | |
| 84 | − : Option.none<SourceAuthSession>(); | |
| 85 | − }), | |
| 63 | + getPendingByWorkspaceSourceAndActor: (input: { | |
| 64 | + workspaceId: SourceAuthSession["workspaceId"]; | |
| 65 | + sourceId: SourceAuthSession["sourceId"]; | |
| 66 | + actorAccountId: SourceAuthSession["actorAccountId"]; | |
| 67 | + }) => | |
| 68 | + client.use( | |
| 69 | + "rows.source_auth_sessions.get_pending_by_workspace_source_actor", | |
| 70 | + async (db) => { | |
| 71 | + const rows = await db | |
| 72 | + .select() | |
| 73 | + .from(tables.sourceAuthSessionsTable) | |
| 74 | + .where( | |
| 75 | + and( | |
| 76 | + eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId), | |
| 77 | + eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId), | |
| 78 | + input.actorAccountId === null | |
| 79 | + ? isNull(tables.sourceAuthSessionsTable.actorAccountId) | |
| 80 | + : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId), | |
| 81 | + eq(tables.sourceAuthSessionsTable.status, "pending"), | |
| 82 | + ), | |
| 83 | + ) | |
| 84 | + .orderBy( | |
| 85 | + asc(tables.sourceAuthSessionsTable.updatedAt), | |
| 86 | + asc(tables.sourceAuthSessionsTable.id), | |
| 87 | + ) | |
| 88 | + .limit(1); | |
| 89 | + | |
| 90 | + const row = firstOption(rows); | |
| 91 | + return Option.isSome(row) | |
| 92 | + ? Option.some(decodeSourceAuthSession(row.value)) | |
| 93 | + : Option.none<SourceAuthSession>(); | |
| 94 | + }, | |
| 95 | + ), | |
| 86 | 96 | |
| 87 | 97 | insert: (session: SourceAuthSession) => |
| 88 | 98 | client.use("rows.source_auth_sessions.insert", async (db) => { |
| @@ -123,7 +133,7 @@ export const createSourceAuthSessionsRepo = ( · 34 unmodified lines | ||
| 123 | 133 | workspaceId: SourceAuthSession["workspaceId"], |
| 124 | 134 | sourceId: SourceAuthSession["sourceId"], |
| 125 | 135 | ) => |
| 126 | − client.use("rows.source_auth_sessions.remove_by_workspace_and_source_id", async (db) => { | |
| 136 | + client.use("rows.source_auth_sessions.remove_by_workspace_source", async (db) => { | |
| 127 | 137 | const deleted = await db |
| 128 | 138 | .delete(tables.sourceAuthSessionsTable) |
| 129 | 139 | .where( |
| @@ -0,0 +1,79 @@ | ||
| 1 | +import { | |
| 2 | + type WorkspaceSourceOauthClient, | |
| 3 | + WorkspaceSourceOauthClientSchema, | |
| 4 | +} from "#schema"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import { Schema } from "effect"; | |
| 7 | +import { and, eq } from "drizzle-orm"; | |
| 8 | + | |
| 9 | +import type { DrizzleClient } from "../client"; | |
| 10 | +import type { DrizzleTables } from "../schema"; | |
| 11 | +import { firstOption, withoutCreatedAt } from "./shared"; | |
| 12 | + | |
| 13 | +const decodeWorkspaceSourceOauthClient = Schema.decodeUnknownSync( | |
| 14 | + WorkspaceSourceOauthClientSchema, | |
| 15 | +); | |
| 16 | + | |
| 17 | +export const createSourceOauthClientsRepo = ( | |
| 18 | + client: DrizzleClient, | |
| 19 | + tables: DrizzleTables, | |
| 20 | +) => ({ | |
| 21 | + getByWorkspaceSourceAndProvider: (input: { | |
| 22 | + workspaceId: WorkspaceSourceOauthClient["workspaceId"]; | |
| 23 | + sourceId: WorkspaceSourceOauthClient["sourceId"]; | |
| 24 | + providerKey: string; | |
| 25 | + }) => | |
| 26 | + client.use("rows.source_oauth_clients.get_by_workspace_source_provider", async (db) => { | |
| 27 | + const rows = await db | |
| 28 | + .select() | |
| 29 | + .from(tables.workspaceSourceOauthClientsTable) | |
| 30 | + .where( | |
| 31 | + and( | |
| 32 | + eq(tables.workspaceSourceOauthClientsTable.workspaceId, input.workspaceId), | |
| 33 | + eq(tables.workspaceSourceOauthClientsTable.sourceId, input.sourceId), | |
| 34 | + eq(tables.workspaceSourceOauthClientsTable.providerKey, input.providerKey), | |
| 35 | + ), | |
| 36 | + ) | |
| 37 | + .limit(1); | |
| 38 | + | |
| 39 | + const row = firstOption(rows); | |
| 40 | + return Option.isSome(row) | |
| 41 | + ? Option.some(decodeWorkspaceSourceOauthClient(row.value)) | |
| 42 | + : Option.none<WorkspaceSourceOauthClient>(); | |
| 43 | + }), | |
| 44 | + | |
| 45 | + upsert: (oauthClient: WorkspaceSourceOauthClient) => | |
| 46 | + client.use("rows.source_oauth_clients.upsert", async (db) => { | |
| 47 | + await db | |
| 48 | + .insert(tables.workspaceSourceOauthClientsTable) | |
| 49 | + .values(oauthClient) | |
| 50 | + .onConflictDoUpdate({ | |
| 51 | + target: [ | |
| 52 | + tables.workspaceSourceOauthClientsTable.workspaceId, | |
| 53 | + tables.workspaceSourceOauthClientsTable.sourceId, | |
| 54 | + tables.workspaceSourceOauthClientsTable.providerKey, | |
| 55 | + ], | |
| 56 | + set: { | |
| 57 | + ...withoutCreatedAt(oauthClient), | |
| 58 | + }, | |
| 59 | + }); | |
| 60 | + }), | |
| 61 | + | |
| 62 | + removeByWorkspaceAndSourceId: (input: { | |
| 63 | + workspaceId: WorkspaceSourceOauthClient["workspaceId"]; | |
| 64 | + sourceId: WorkspaceSourceOauthClient["sourceId"]; | |
| 65 | + }) => | |
| 66 | + client.use("rows.source_oauth_clients.remove_by_workspace_source", async (db) => { | |
| 67 | + const deleted = await db | |
| 68 | + .delete(tables.workspaceSourceOauthClientsTable) | |
| 69 | + .where( | |
| 70 | + and( | |
| 71 | + eq(tables.workspaceSourceOauthClientsTable.workspaceId, input.workspaceId), | |
| 72 | + eq(tables.workspaceSourceOauthClientsTable.sourceId, input.sourceId), | |
| 73 | + ), | |
| 74 | + ) | |
| 75 | + .returning(); | |
| 76 | + | |
| 77 | + return deleted.length; | |
| 78 | + }), | |
| 79 | +}); | |
| @@ -0,0 +1,77 @@ | ||
| 1 | +import { | |
| 2 | + type StoredSourceRecipeDocumentRecord, | |
| 3 | + StoredSourceRecipeDocumentRecordSchema, | |
| 4 | +} from "#schema"; | |
| 5 | +import { Schema } from "effect"; | |
| 6 | +import { asc, eq, inArray } from "drizzle-orm"; | |
| 7 | + | |
| 8 | +import type { DrizzleClient } from "../client"; | |
| 9 | +import type { DrizzleTables } from "../schema"; | |
| 10 | + | |
| 11 | +const decodeStoredSourceRecipeDocumentRecord = Schema.decodeUnknownSync( | |
| 12 | + StoredSourceRecipeDocumentRecordSchema, | |
| 13 | +); | |
| 14 | + | |
| 15 | +export const createSourceRecipeDocumentsRepo = ( | |
| 16 | + client: DrizzleClient, | |
| 17 | + tables: DrizzleTables, | |
| 18 | +) => ({ | |
| 19 | + listByRevisionId: (recipeRevisionId: StoredSourceRecipeDocumentRecord["recipeRevisionId"]) => | |
| 20 | + client.use("rows.source_recipe_documents.list_by_revision", async (db) => { | |
| 21 | + const rows = await db | |
| 22 | + .select() | |
| 23 | + .from(tables.sourceRecipeDocumentsTable) | |
| 24 | + .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionId)) | |
| 25 | + .orderBy( | |
| 26 | + asc(tables.sourceRecipeDocumentsTable.documentKind), | |
| 27 | + asc(tables.sourceRecipeDocumentsTable.documentKey), | |
| 28 | + ); | |
| 29 | + | |
| 30 | + return rows.map((row) => decodeStoredSourceRecipeDocumentRecord(row)); | |
| 31 | + }), | |
| 32 | + | |
| 33 | + listByRevisionIds: ( | |
| 34 | + recipeRevisionIds: readonly StoredSourceRecipeDocumentRecord["recipeRevisionId"][], | |
| 35 | + ) => | |
| 36 | + client.use("rows.source_recipe_documents.list_by_revisions", async (db) => { | |
| 37 | + if (recipeRevisionIds.length === 0) { | |
| 38 | + return [] as StoredSourceRecipeDocumentRecord[]; | |
| 39 | + } | |
| 40 | + | |
| 41 | + const rows = await db | |
| 42 | + .select() | |
| 43 | + .from(tables.sourceRecipeDocumentsTable) | |
| 44 | + .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, [...recipeRevisionIds])) | |
| 45 | + .orderBy( | |
| 46 | + asc(tables.sourceRecipeDocumentsTable.recipeRevisionId), | |
| 47 | + asc(tables.sourceRecipeDocumentsTable.documentKind), | |
| 48 | + asc(tables.sourceRecipeDocumentsTable.documentKey), | |
| 49 | + ); | |
| 50 | + | |
| 51 | + return rows.map((row) => decodeStoredSourceRecipeDocumentRecord(row)); | |
| 52 | + }), | |
| 53 | + | |
| 54 | + replaceForRevision: (input: { | |
| 55 | + recipeRevisionId: StoredSourceRecipeDocumentRecord["recipeRevisionId"]; | |
| 56 | + documents: readonly StoredSourceRecipeDocumentRecord[]; | |
| 57 | + }) => | |
| 58 | + client.useTx("rows.source_recipe_documents.replace_for_revision", async (tx) => { | |
| 59 | + await tx | |
| 60 | + .delete(tables.sourceRecipeDocumentsTable) | |
| 61 | + .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, input.recipeRevisionId)); | |
| 62 | + | |
| 63 | + if (input.documents.length > 0) { | |
| 64 | + await tx.insert(tables.sourceRecipeDocumentsTable).values([...input.documents]); | |
| 65 | + } | |
| 66 | + }), | |
| 67 | + | |
| 68 | + removeByRevisionId: (recipeRevisionId: StoredSourceRecipeDocumentRecord["recipeRevisionId"]) => | |
| 69 | + client.use("rows.source_recipe_documents.remove_by_revision", async (db) => { | |
| 70 | + const deleted = await db | |
| 71 | + .delete(tables.sourceRecipeDocumentsTable) | |
| 72 | + .where(eq(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionId)) | |
| 73 | + .returning(); | |
| 74 | + | |
| 75 | + return deleted.length; | |
| 76 | + }), | |
| 77 | +}); | |
| @@ -0,0 +1,77 @@ | ||
| 1 | +import { | |
| 2 | + type StoredSourceRecipeOperationRecord, | |
| 3 | + StoredSourceRecipeOperationRecordSchema, | |
| 4 | +} from "#schema"; | |
| 5 | +import { Schema } from "effect"; | |
| 6 | +import { asc, eq, inArray } from "drizzle-orm"; | |
| 7 | + | |
| 8 | +import type { DrizzleClient } from "../client"; | |
| 9 | +import type { DrizzleTables } from "../schema"; | |
| 10 | + | |
| 11 | +const decodeStoredSourceRecipeOperationRecord = Schema.decodeUnknownSync( | |
| 12 | + StoredSourceRecipeOperationRecordSchema, | |
| 13 | +); | |
| 14 | + | |
| 15 | +export const createSourceRecipeOperationsRepo = ( | |
| 16 | + client: DrizzleClient, | |
| 17 | + tables: DrizzleTables, | |
| 18 | +) => ({ | |
| 19 | + listByRevisionId: (recipeRevisionId: StoredSourceRecipeOperationRecord["recipeRevisionId"]) => | |
| 20 | + client.use("rows.source_recipe_operations.list_by_revision", async (db) => { | |
| 21 | + const rows = await db | |
| 22 | + .select() | |
| 23 | + .from(tables.sourceRecipeOperationsTable) | |
| 24 | + .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionId)) | |
| 25 | + .orderBy( | |
| 26 | + asc(tables.sourceRecipeOperationsTable.toolId), | |
| 27 | + asc(tables.sourceRecipeOperationsTable.id), | |
| 28 | + ); | |
| 29 | + | |
| 30 | + return rows.map((row) => decodeStoredSourceRecipeOperationRecord(row)); | |
| 31 | + }), | |
| 32 | + | |
| 33 | + listByRevisionIds: ( | |
| 34 | + recipeRevisionIds: readonly StoredSourceRecipeOperationRecord["recipeRevisionId"][], | |
| 35 | + ) => | |
| 36 | + client.use("rows.source_recipe_operations.list_by_revisions", async (db) => { | |
| 37 | + if (recipeRevisionIds.length === 0) { | |
| 38 | + return [] as StoredSourceRecipeOperationRecord[]; | |
| 39 | + } | |
| 40 | + | |
| 41 | + const rows = await db | |
| 42 | + .select() | |
| 43 | + .from(tables.sourceRecipeOperationsTable) | |
| 44 | + .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, [...recipeRevisionIds])) | |
| 45 | + .orderBy( | |
| 46 | + asc(tables.sourceRecipeOperationsTable.recipeRevisionId), | |
| 47 | + asc(tables.sourceRecipeOperationsTable.toolId), | |
| 48 | + asc(tables.sourceRecipeOperationsTable.id), | |
| 49 | + ); | |
| 50 | + | |
| 51 | + return rows.map((row) => decodeStoredSourceRecipeOperationRecord(row)); | |
| 52 | + }), | |
| 53 | + | |
| 54 | + replaceForRevision: (input: { | |
| 55 | + recipeRevisionId: StoredSourceRecipeOperationRecord["recipeRevisionId"]; | |
| 56 | + operations: readonly StoredSourceRecipeOperationRecord[]; | |
| 57 | + }) => | |
| 58 | + client.useTx("rows.source_recipe_operations.replace_for_revision", async (tx) => { | |
| 59 | + await tx | |
| 60 | + .delete(tables.sourceRecipeOperationsTable) | |
| 61 | + .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, input.recipeRevisionId)); | |
| 62 | + | |
| 63 | + if (input.operations.length > 0) { | |
| 64 | + await tx.insert(tables.sourceRecipeOperationsTable).values([...input.operations]); | |
| 65 | + } | |
| 66 | + }), | |
| 67 | + | |
| 68 | + removeByRevisionId: (recipeRevisionId: StoredSourceRecipeOperationRecord["recipeRevisionId"]) => | |
| 69 | + client.use("rows.source_recipe_operations.remove_by_revision", async (db) => { | |
| 70 | + const deleted = await db | |
| 71 | + .delete(tables.sourceRecipeOperationsTable) | |
| 72 | + .where(eq(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionId)) | |
| 73 | + .returning(); | |
| 74 | + | |
| 75 | + return deleted.length; | |
| 76 | + }), | |
| 77 | +}); | |
| @@ -0,0 +1,111 @@ | ||
| 1 | +import { | |
| 2 | + type StoredSourceRecipeRevisionRecord, | |
| 3 | + StoredSourceRecipeRevisionRecordSchema, | |
| 4 | +} from "#schema"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import { Schema } from "effect"; | |
| 7 | +import { asc, eq, inArray } from "drizzle-orm"; | |
| 8 | + | |
| 9 | +import type { DrizzleClient } from "../client"; | |
| 10 | +import type { DrizzleTables } from "../schema"; | |
| 11 | +import { firstOption, withoutCreatedAt } from "./shared"; | |
| 12 | + | |
| 13 | +const decodeStoredSourceRecipeRevisionRecord = Schema.decodeUnknownSync( | |
| 14 | + StoredSourceRecipeRevisionRecordSchema, | |
| 15 | +); | |
| 16 | + | |
| 17 | +type Mutable<T> = { -readonly [K in keyof T]: T[K] }; | |
| 18 | +type RecipeRevisionPatch = Partial< | |
| 19 | + Omit<Mutable<StoredSourceRecipeRevisionRecord>, "id" | "recipeId" | "revisionNumber" | "createdAt"> | |
| 20 | +>; | |
| 21 | + | |
| 22 | +export const createSourceRecipeRevisionsRepo = ( | |
| 23 | + client: DrizzleClient, | |
| 24 | + tables: DrizzleTables, | |
| 25 | +) => ({ | |
| 26 | + listByRecipeId: (recipeId: StoredSourceRecipeRevisionRecord["recipeId"]) => | |
| 27 | + client.use("rows.source_recipe_revisions.list_by_recipe", async (db) => { | |
| 28 | + const rows = await db | |
| 29 | + .select() | |
| 30 | + .from(tables.sourceRecipeRevisionsTable) | |
| 31 | + .where(eq(tables.sourceRecipeRevisionsTable.recipeId, recipeId)) | |
| 32 | + .orderBy( | |
| 33 | + asc(tables.sourceRecipeRevisionsTable.revisionNumber), | |
| 34 | + asc(tables.sourceRecipeRevisionsTable.createdAt), | |
| 35 | + ); | |
| 36 | + | |
| 37 | + return rows.map((row) => decodeStoredSourceRecipeRevisionRecord(row)); | |
| 38 | + }), | |
| 39 | + | |
| 40 | + listByIds: (ids: readonly StoredSourceRecipeRevisionRecord["id"][]) => | |
| 41 | + client.use("rows.source_recipe_revisions.list_by_ids", async (db) => { | |
| 42 | + if (ids.length === 0) { | |
| 43 | + return [] as StoredSourceRecipeRevisionRecord[]; | |
| 44 | + } | |
| 45 | + | |
| 46 | + const rows = await db | |
| 47 | + .select() | |
| 48 | + .from(tables.sourceRecipeRevisionsTable) | |
| 49 | + .where(inArray(tables.sourceRecipeRevisionsTable.id, [...ids])) | |
| 50 | + .orderBy( | |
| 51 | + asc(tables.sourceRecipeRevisionsTable.recipeId), | |
| 52 | + asc(tables.sourceRecipeRevisionsTable.revisionNumber), | |
| 53 | + ); | |
| 54 | + | |
| 55 | + return rows.map((row) => decodeStoredSourceRecipeRevisionRecord(row)); | |
| 56 | + }), | |
| 57 | + | |
| 58 | + getById: (id: StoredSourceRecipeRevisionRecord["id"]) => | |
| 59 | + client.use("rows.source_recipe_revisions.get_by_id", async (db) => { | |
| 60 | + const rows = await db | |
| 61 | + .select() | |
| 62 | + .from(tables.sourceRecipeRevisionsTable) | |
| 63 | + .where(eq(tables.sourceRecipeRevisionsTable.id, id)) | |
| 64 | + .limit(1); | |
| 65 | + | |
| 66 | + const row = firstOption(rows); | |
| 67 | + return Option.isSome(row) | |
| 68 | + ? Option.some(decodeStoredSourceRecipeRevisionRecord(row.value)) | |
| 69 | + : Option.none<StoredSourceRecipeRevisionRecord>(); | |
| 70 | + }), | |
| 71 | + | |
| 72 | + upsert: (revision: StoredSourceRecipeRevisionRecord) => | |
| 73 | + client.use("rows.source_recipe_revisions.upsert", async (db) => { | |
| 74 | + await db | |
| 75 | + .insert(tables.sourceRecipeRevisionsTable) | |
| 76 | + .values(revision) | |
| 77 | + .onConflictDoUpdate({ | |
| 78 | + target: [tables.sourceRecipeRevisionsTable.id], | |
| 79 | + set: { | |
| 80 | + ...withoutCreatedAt(revision), | |
| 81 | + }, | |
| 82 | + }); | |
| 83 | + }), | |
| 84 | + | |
| 85 | + update: ( | |
| 86 | + id: StoredSourceRecipeRevisionRecord["id"], | |
| 87 | + patch: RecipeRevisionPatch, | |
| 88 | + ) => | |
| 89 | + client.use("rows.source_recipe_revisions.update", async (db) => { | |
| 90 | + const rows = await db | |
| 91 | + .update(tables.sourceRecipeRevisionsTable) | |
| 92 | + .set(patch) | |
| 93 | + .where(eq(tables.sourceRecipeRevisionsTable.id, id)) | |
| 94 | + .returning(); | |
| 95 | + | |
| 96 | + const row = firstOption(rows); | |
| 97 | + return Option.isSome(row) | |
| 98 | + ? Option.some(decodeStoredSourceRecipeRevisionRecord(row.value)) | |
| 99 | + : Option.none<StoredSourceRecipeRevisionRecord>(); | |
| 100 | + }), | |
| 101 | + | |
| 102 | + removeByRecipeId: (recipeId: StoredSourceRecipeRevisionRecord["recipeId"]) => | |
| 103 | + client.use("rows.source_recipe_revisions.remove_by_recipe", async (db) => { | |
| 104 | + const deleted = await db | |
| 105 | + .delete(tables.sourceRecipeRevisionsTable) | |
| 106 | + .where(eq(tables.sourceRecipeRevisionsTable.recipeId, recipeId)) | |
| 107 | + .returning(); | |
| 108 | + | |
| 109 | + return deleted.length; | |
| 110 | + }), | |
| 111 | +}); | |
| @@ -0,0 +1,67 @@ | ||
| 1 | +import { | |
| 2 | + type StoredSourceRecipeRecord, | |
| 3 | + StoredSourceRecipeRecordSchema, | |
| 4 | +} from "#schema"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 6 | +import { Schema } from "effect"; | |
| 7 | +import { asc, eq } from "drizzle-orm"; | |
| 8 | + | |
| 9 | +import type { DrizzleClient } from "../client"; | |
| 10 | +import type { DrizzleTables } from "../schema"; | |
| 11 | +import { firstOption, withoutCreatedAt } from "./shared"; | |
| 12 | + | |
| 13 | +const decodeStoredSourceRecipeRecord = Schema.decodeUnknownSync( | |
| 14 | + StoredSourceRecipeRecordSchema, | |
| 15 | +); | |
| 16 | + | |
| 17 | +export const createSourceRecipesRepo = ( | |
| 18 | + client: DrizzleClient, | |
| 19 | + tables: DrizzleTables, | |
| 20 | +) => ({ | |
| 21 | + listByUpdatedAt: () => | |
| 22 | + client.use("rows.source_recipes.list", async (db) => { | |
| 23 | + const rows = await db | |
| 24 | + .select() | |
| 25 | + .from(tables.sourceRecipesTable) | |
| 26 | + .orderBy(asc(tables.sourceRecipesTable.updatedAt), asc(tables.sourceRecipesTable.id)); | |
| 27 | + | |
| 28 | + return rows.map((row) => decodeStoredSourceRecipeRecord(row)); | |
| 29 | + }), | |
| 30 | + | |
| 31 | + getById: (id: StoredSourceRecipeRecord["id"]) => | |
| 32 | + client.use("rows.source_recipes.get_by_id", async (db) => { | |
| 33 | + const rows = await db | |
| 34 | + .select() | |
| 35 | + .from(tables.sourceRecipesTable) | |
| 36 | + .where(eq(tables.sourceRecipesTable.id, id)) | |
| 37 | + .limit(1); | |
| 38 | + | |
| 39 | + const row = firstOption(rows); | |
| 40 | + return Option.isSome(row) | |
| 41 | + ? Option.some(decodeStoredSourceRecipeRecord(row.value)) | |
| 42 | + : Option.none<StoredSourceRecipeRecord>(); | |
| 43 | + }), | |
| 44 | + | |
| 45 | + upsert: (recipe: StoredSourceRecipeRecord) => | |
| 46 | + client.use("rows.source_recipes.upsert", async (db) => { | |
| 47 | + await db | |
| 48 | + .insert(tables.sourceRecipesTable) | |
| 49 | + .values(recipe) | |
| 50 | + .onConflictDoUpdate({ | |
| 51 | + target: [tables.sourceRecipesTable.id], | |
| 52 | + set: { | |
| 53 | + ...withoutCreatedAt(recipe), | |
| 54 | + }, | |
| 55 | + }); | |
| 56 | + }), | |
| 57 | + | |
| 58 | + removeById: (id: StoredSourceRecipeRecord["id"]) => | |
| 59 | + client.use("rows.source_recipes.remove", async (db) => { | |
| 60 | + const deleted = await db | |
| 61 | + .delete(tables.sourceRecipesTable) | |
| 62 | + .where(eq(tables.sourceRecipesTable.id, id)) | |
| 63 | + .returning(); | |
| 64 | + | |
| 65 | + return deleted.length > 0; | |
| 66 | + }), | |
| 67 | +}); | |
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | } from "#schema"; |
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | import { Schema } from "effect"; |
| 7 | −import { and, asc, eq, or } from "drizzle-orm"; | |
| 7 | +import { and, asc, count, eq, or } from "drizzle-orm"; | |
| 8 | 8 | |
| 9 | 9 | import type { DrizzleClient } from "../client"; |
| 10 | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -18,12 +18,15 @@ const toSourceUpdateSet = ( · 7 unmodified lines | ||
| 18 | 18 | ): Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> => { |
| 19 | 19 | const set: Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> = {}; |
| 20 | 20 | |
| 21 | + if (patch.recipeId !== undefined) set.recipeId = patch.recipeId; | |
| 22 | + if (patch.recipeRevisionId !== undefined) set.recipeRevisionId = patch.recipeRevisionId; | |
| 21 | 23 | if (patch.name !== undefined) set.name = patch.name; |
| 22 | 24 | if (patch.kind !== undefined) set.kind = patch.kind; |
| 23 | 25 | if (patch.endpoint !== undefined) set.endpoint = patch.endpoint; |
| 24 | 26 | if (patch.status !== undefined) set.status = patch.status; |
| 25 | 27 | if (patch.enabled !== undefined) set.enabled = patch.enabled; |
| 26 | 28 | if (patch.namespace !== undefined) set.namespace = patch.namespace; |
| 29 | + if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson; | |
| 27 | 30 | if (patch.transport !== undefined) set.transport = patch.transport; |
| 28 | 31 | if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson; |
| 29 | 32 | if (patch.headersJson !== undefined) set.headersJson = patch.headersJson; |
| @@ -43,6 +46,20 @@ export const createSourcesRepo = ( · 13 unmodified lines | ||
| 43 | 46 | client: DrizzleClient, |
| 44 | 47 | tables: DrizzleTables, |
| 45 | 48 | ) => ({ |
| 49 | + listAll: () => | |
| 50 | + client.use("rows.sources.list_all", async (db) => { | |
| 51 | + const rows = await db | |
| 52 | + .select() | |
| 53 | + .from(tables.sourcesTable) | |
| 54 | + .orderBy( | |
| 55 | + asc(tables.sourcesTable.workspaceId), | |
| 56 | + asc(tables.sourcesTable.updatedAt), | |
| 57 | + asc(tables.sourcesTable.sourceId), | |
| 58 | + ); | |
| 59 | + | |
| 60 | + return rows.map((row) => decodeStoredSourceRecord(row)); | |
| 61 | + }), | |
| 62 | + | |
| 46 | 63 | listByWorkspaceId: (workspaceId: StoredSourceRecord["workspaceId"]) => |
| 47 | 64 | client.use("rows.sources.list_by_workspace", async (db) => { |
| 48 | 65 | const rows = await db |
| @@ -76,6 +93,26 @@ export const createSourcesRepo = ( · 27 unmodified lines | ||
| 76 | 93 | : Option.none<StoredSourceRecord>(); |
| 77 | 94 | }), |
| 78 | 95 | |
| 96 | + countByRecipeId: (recipeId: StoredSourceRecord["recipeId"]) => | |
| 97 | + client.use("rows.sources.count_by_recipe", async (db) => { | |
| 98 | + const rows = await db | |
| 99 | + .select({ value: count() }) | |
| 100 | + .from(tables.sourcesTable) | |
| 101 | + .where(eq(tables.sourcesTable.recipeId, recipeId)); | |
| 102 | + | |
| 103 | + return Number(rows[0]?.value ?? 0); | |
| 104 | + }), | |
| 105 | + | |
| 106 | + countByRecipeRevisionId: (recipeRevisionId: StoredSourceRecord["recipeRevisionId"]) => | |
| 107 | + client.use("rows.sources.count_by_recipe_revision", async (db) => { | |
| 108 | + const rows = await db | |
| 109 | + .select({ value: count() }) | |
| 110 | + .from(tables.sourcesTable) | |
| 111 | + .where(eq(tables.sourcesTable.recipeRevisionId, recipeRevisionId)); | |
| 112 | + | |
| 113 | + return Number(rows[0]?.value ?? 0); | |
| 114 | + }), | |
| 115 | + | |
| 79 | 116 | insert: (source: StoredSourceRecord) => |
| 80 | 117 | client.use("rows.sources.insert", async (db) => { |
| 81 | 118 | await db.insert(tables.sourcesTable).values(encodeStoredSourceRecord(source)); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { type Workspace, WorkspaceSchema } from "#schema"; |
| 2 | 2 | import * as Option from "effect/Option"; |
| 3 | 3 | import { Schema } from "effect"; |
| 4 | −import { and, asc, eq, inArray } from "drizzle-orm"; | |
| 4 | +import { and, asc, eq, inArray, or } from "drizzle-orm"; | |
| 5 | 5 | |
| 6 | 6 | import type { DrizzleClient } from "../client"; |
| 7 | 7 | import type { DrizzleTables } from "../schema"; |
| @@ -69,7 +69,16 @@ export const createWorkspacesRepo = ( · 61 unmodified lines | ||
| 69 | 69 | .select({ id: tables.executionsTable.id }) |
| 70 | 70 | .from(tables.executionsTable) |
| 71 | 71 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| 72 | + const sourceRows = await tx | |
| 73 | + .select({ | |
| 74 | + sourceId: tables.sourcesTable.sourceId, | |
| 75 | + recipeId: tables.sourcesTable.recipeId, | |
| 76 | + }) | |
| 77 | + .from(tables.sourcesTable) | |
| 78 | + .where(eq(tables.sourcesTable.workspaceId, workspaceId)); | |
| 72 | 79 | const executionIds = executionRows.map((execution) => execution.id); |
| 80 | + const sourceIds = sourceRows.map((source) => source.sourceId); | |
| 81 | + const recipeIds = sourceRows.map((source) => source.recipeId); | |
| 73 | 82 | const credentials = await tx |
| 74 | 83 | .select({ |
| 75 | 84 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| @@ -79,6 +88,26 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | ||
| 79 | 88 | }) |
| 80 | 89 | .from(tables.credentialsTable) |
| 81 | 90 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); |
| 91 | + const recipeRevisionRows = recipeIds.length > 0 | |
| 92 | + ? await tx | |
| 93 | + .select({ id: tables.sourceRecipeRevisionsTable.id }) | |
| 94 | + .from(tables.sourceRecipeRevisionsTable) | |
| 95 | + .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)) | |
| 96 | + : []; | |
| 97 | + const recipeRevisionIds = recipeRevisionRows.map((revision) => revision.id); | |
| 98 | + const toolArtifactPaths = sourceIds.length > 0 | |
| 99 | + ? ( | |
| 100 | + await tx | |
| 101 | + .select({ path: tables.toolArtifactsTable.path }) | |
| 102 | + .from(tables.toolArtifactsTable) | |
| 103 | + .where( | |
| 104 | + and( | |
| 105 | + eq(tables.toolArtifactsTable.workspaceId, workspaceId), | |
| 106 | + inArray(tables.toolArtifactsTable.sourceId, sourceIds), | |
| 107 | + ), | |
| 108 | + ) | |
| 109 | + ).map((row) => row.path) | |
| 110 | + : []; | |
| 82 | 111 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 83 | 112 | |
| 84 | 113 | if (executionIds.length > 0) { |
| @@ -87,6 +116,39 @@ export const createWorkspacesRepo = ( · 2 unmodified lines | ||
| 87 | 116 | .where(inArray(tables.executionInteractionsTable.executionId, executionIds)); |
| 88 | 117 | } |
| 89 | 118 | |
| 119 | + if (toolArtifactPaths.length > 0) { | |
| 120 | + await tx | |
| 121 | + .delete(tables.toolArtifactParametersTable) | |
| 122 | + .where( | |
| 123 | + and( | |
| 124 | + eq(tables.toolArtifactParametersTable.workspaceId, workspaceId), | |
| 125 | + or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactParametersTable.path, path))), | |
| 126 | + ), | |
| 127 | + ); | |
| 128 | + | |
| 129 | + await tx | |
| 130 | + .delete(tables.toolArtifactRequestBodyContentTypesTable) | |
| 131 | + .where( | |
| 132 | + and( | |
| 133 | + eq(tables.toolArtifactRequestBodyContentTypesTable.workspaceId, workspaceId), | |
| 134 | + or( | |
| 135 | + ...toolArtifactPaths.map((path) => | |
| 136 | + eq(tables.toolArtifactRequestBodyContentTypesTable.path, path) | |
| 137 | + ), | |
| 138 | + ), | |
| 139 | + ), | |
| 140 | + ); | |
| 141 | + | |
| 142 | + await tx | |
| 143 | + .delete(tables.toolArtifactRefHintKeysTable) | |
| 144 | + .where( | |
| 145 | + and( | |
| 146 | + eq(tables.toolArtifactRefHintKeysTable.workspaceId, workspaceId), | |
| 147 | + or(...toolArtifactPaths.map((path) => eq(tables.toolArtifactRefHintKeysTable.path, path))), | |
| 148 | + ), | |
| 149 | + ); | |
| 150 | + } | |
| 151 | + | |
| 90 | 152 | await tx |
| 91 | 153 | .delete(tables.executionsTable) |
| 92 | 154 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| @@ -96,17 +158,41 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | ||
| 96 | 158 | .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId)); |
| 97 | 159 | |
| 98 | 160 | await tx |
| 99 | − .delete(tables.sourceCredentialBindingsTable) | |
| 100 | − .where(eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId)); | |
| 161 | + .delete(tables.workspaceSourceOauthClientsTable) | |
| 162 | + .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId)); | |
| 101 | 163 | |
| 102 | 164 | await tx |
| 103 | 165 | .delete(tables.credentialsTable) |
| 104 | 166 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); |
| 105 | 167 | |
| 168 | + await tx | |
| 169 | + .delete(tables.toolArtifactsTable) | |
| 170 | + .where(eq(tables.toolArtifactsTable.workspaceId, workspaceId)); | |
| 171 | + | |
| 106 | 172 | await tx |
| 107 | 173 | .delete(tables.sourcesTable) |
| 108 | 174 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); |
| 109 | 175 | |
| 176 | + if (recipeRevisionIds.length > 0) { | |
| 177 | + await tx | |
| 178 | + .delete(tables.sourceRecipeDocumentsTable) | |
| 179 | + .where(inArray(tables.sourceRecipeDocumentsTable.recipeRevisionId, recipeRevisionIds)); | |
| 180 | + | |
| 181 | + await tx | |
| 182 | + .delete(tables.sourceRecipeOperationsTable) | |
| 183 | + .where(inArray(tables.sourceRecipeOperationsTable.recipeRevisionId, recipeRevisionIds)); | |
| 184 | + } | |
| 185 | + | |
| 186 | + if (recipeIds.length > 0) { | |
| 187 | + await tx | |
| 188 | + .delete(tables.sourceRecipeRevisionsTable) | |
| 189 | + .where(inArray(tables.sourceRecipeRevisionsTable.recipeId, recipeIds)); | |
| 190 | + | |
| 191 | + await tx | |
| 192 | + .delete(tables.sourceRecipesTable) | |
| 193 | + .where(inArray(tables.sourceRecipesTable.id, recipeIds)); | |
| 194 | + } | |
| 195 | + | |
| 110 | 196 | await tx |
| 111 | 197 | .delete(tables.policiesTable) |
| 112 | 198 | .where( |
| @@ -16,12 +16,16 @@ export const tableNames = { · 15 unmodified lines | ||
| 16 | 16 | organizationMemberships: "organization_memberships", |
| 17 | 17 | workspaces: "workspaces", |
| 18 | 18 | sources: "sources", |
| 19 | + sourceRecipes: "source_recipes", | |
| 20 | + sourceRecipeRevisions: "source_recipe_revisions", | |
| 21 | + sourceRecipeDocuments: "source_recipe_documents", | |
| 22 | + sourceRecipeOperations: "source_recipe_operations", | |
| 19 | 23 | toolArtifacts: "tool_artifacts", |
| 20 | 24 | toolArtifactParameters: "tool_artifact_parameters", |
| 21 | 25 | toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types", |
| 22 | 26 | toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys", |
| 23 | − credentials: "credentials", | |
| 24 | − sourceCredentialBindings: "source_credential_bindings", | |
| 27 | + credentials: "workspace_source_credentials", | |
| 28 | + workspaceSourceOauthClients: "workspace_source_oauth_clients", | |
| 25 | 29 | secretMaterials: "secret_materials", |
| 26 | 30 | sourceAuthSessions: "source_auth_sessions", |
| 27 | 31 | policies: "policies", |
| @@ -133,12 +137,15 @@ export const sourcesTable = pgTable( · 105 unmodified lines | ||
| 133 | 137 | { |
| 134 | 138 | workspaceId: text("workspace_id").notNull(), |
| 135 | 139 | sourceId: text("source_id").notNull(), |
| 140 | + recipeId: text("recipe_id").notNull(), | |
| 141 | + recipeRevisionId: text("recipe_revision_id").notNull(), | |
| 136 | 142 | name: text("name").notNull(), |
| 137 | 143 | kind: text("kind").notNull(), |
| 138 | 144 | endpoint: text("endpoint").notNull(), |
| 139 | 145 | status: text("status").notNull(), |
| 140 | 146 | enabled: boolean("enabled").notNull(), |
| 141 | 147 | namespace: text("namespace"), |
| 148 | + bindingConfigJson: text("binding_config_json"), | |
| 142 | 149 | transport: text("transport"), |
| 143 | 150 | queryParamsJson: text("query_params_json"), |
| 144 | 151 | headersJson: text("headers_json"), |
| @@ -159,6 +166,12 @@ export const sourcesTable = pgTable( · 14 unmodified lines | ||
| 159 | 166 | table.updatedAt, |
| 160 | 167 | table.sourceId, |
| 161 | 168 | ), |
| 169 | + index("sources_workspace_recipe_idx").on( | |
| 170 | + table.workspaceId, | |
| 171 | + table.recipeId, | |
| 172 | + table.updatedAt, | |
| 173 | + table.sourceId, | |
| 174 | + ), | |
| 162 | 175 | uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name), |
| 163 | 176 | check( |
| 164 | 177 | "sources_kind_check", |
| @@ -175,11 +188,187 @@ export const sourcesTable = pgTable( · 10 unmodified lines | ||
| 175 | 188 | ], |
| 176 | 189 | ); |
| 177 | 190 | |
| 191 | +export const sourceRecipesTable = pgTable( | |
| 192 | + tableNames.sourceRecipes, | |
| 193 | + { | |
| 194 | + id: text("id").notNull().primaryKey(), | |
| 195 | + kind: text("kind").notNull(), | |
| 196 | + importerKind: text("importer_kind").notNull(), | |
| 197 | + providerKey: text("provider_key").notNull(), | |
| 198 | + name: text("name").notNull(), | |
| 199 | + summary: text("summary"), | |
| 200 | + visibility: text("visibility").notNull(), | |
| 201 | + latestRevisionId: text("latest_revision_id").notNull(), | |
| 202 | + createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 203 | + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 204 | + }, | |
| 205 | + (table) => [ | |
| 206 | + index("source_recipes_provider_updated_idx").on( | |
| 207 | + table.providerKey, | |
| 208 | + table.updatedAt, | |
| 209 | + table.id, | |
| 210 | + ), | |
| 211 | + index("source_recipes_visibility_updated_idx").on( | |
| 212 | + table.visibility, | |
| 213 | + table.updatedAt, | |
| 214 | + table.id, | |
| 215 | + ), | |
| 216 | + check( | |
| 217 | + "source_recipes_kind_check", | |
| 218 | + sql`${table.kind} in ('http_recipe', 'graphql_recipe', 'mcp_recipe', 'internal_recipe')`, | |
| 219 | + ), | |
| 220 | + check( | |
| 221 | + "source_recipes_importer_kind_check", | |
| 222 | + sql`${table.importerKind} in ( | |
| 223 | + 'openapi', | |
| 224 | + 'google_discovery', | |
| 225 | + 'postman_collection', | |
| 226 | + 'snippet_bundle', | |
| 227 | + 'graphql_introspection', | |
| 228 | + 'mcp_manifest', | |
| 229 | + 'internal_manifest' | |
| 230 | + )`, | |
| 231 | + ), | |
| 232 | + check( | |
| 233 | + "source_recipes_visibility_check", | |
| 234 | + sql`${table.visibility} in ('private', 'workspace', 'organization', 'public')`, | |
| 235 | + ), | |
| 236 | + ], | |
| 237 | +); | |
| 238 | + | |
| 239 | +export const sourceRecipeRevisionsTable = pgTable( | |
| 240 | + tableNames.sourceRecipeRevisions, | |
| 241 | + { | |
| 242 | + id: text("id").notNull().primaryKey(), | |
| 243 | + recipeId: text("recipe_id").notNull(), | |
| 244 | + revisionNumber: bigint("revision_number", { mode: "number" }).notNull(), | |
| 245 | + sourceConfigJson: text("source_config_json").notNull(), | |
| 246 | + manifestJson: text("manifest_json"), | |
| 247 | + manifestHash: text("manifest_hash"), | |
| 248 | + createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 249 | + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 250 | + }, | |
| 251 | + (table) => [ | |
| 252 | + uniqueIndex("source_recipe_revisions_recipe_revision_idx").on( | |
| 253 | + table.recipeId, | |
| 254 | + table.revisionNumber, | |
| 255 | + ), | |
| 256 | + uniqueIndex("source_recipe_revisions_recipe_manifest_idx").on( | |
| 257 | + table.recipeId, | |
| 258 | + table.manifestHash, | |
| 259 | + ), | |
| 260 | + index("source_recipe_revisions_recipe_created_idx").on( | |
| 261 | + table.recipeId, | |
| 262 | + table.createdAt, | |
| 263 | + table.id, | |
| 264 | + ), | |
| 265 | + ], | |
| 266 | +); | |
| 267 | + | |
| 268 | +export const sourceRecipeDocumentsTable = pgTable( | |
| 269 | + tableNames.sourceRecipeDocuments, | |
| 270 | + { | |
| 271 | + id: text("id").notNull().primaryKey(), | |
| 272 | + recipeRevisionId: text("recipe_revision_id").notNull(), | |
| 273 | + documentKind: text("document_kind").notNull(), | |
| 274 | + documentKey: text("document_key").notNull(), | |
| 275 | + contentText: text("content_text").notNull(), | |
| 276 | + contentHash: text("content_hash").notNull(), | |
| 277 | + fetchedAt: bigint("fetched_at", { mode: "number" }), | |
| 278 | + createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 279 | + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 280 | + }, | |
| 281 | + (table) => [ | |
| 282 | + uniqueIndex("source_recipe_documents_revision_kind_key_idx").on( | |
| 283 | + table.recipeRevisionId, | |
| 284 | + table.documentKind, | |
| 285 | + table.documentKey, | |
| 286 | + ), | |
| 287 | + index("source_recipe_documents_revision_created_idx").on( | |
| 288 | + table.recipeRevisionId, | |
| 289 | + table.createdAt, | |
| 290 | + table.id, | |
| 291 | + ), | |
| 292 | + check( | |
| 293 | + "source_recipe_documents_kind_check", | |
| 294 | + sql`${table.documentKind} in ( | |
| 295 | + 'google_discovery', | |
| 296 | + 'openapi', | |
| 297 | + 'postman_collection', | |
| 298 | + 'postman_environment', | |
| 299 | + 'graphql_introspection', | |
| 300 | + 'mcp_manifest' | |
| 301 | + )`, | |
| 302 | + ), | |
| 303 | + ], | |
| 304 | +); | |
| 305 | + | |
| 306 | +export const sourceRecipeOperationsTable = pgTable( | |
| 307 | + tableNames.sourceRecipeOperations, | |
| 308 | + { | |
| 309 | + id: text("id").notNull().primaryKey(), | |
| 310 | + recipeRevisionId: text("recipe_revision_id").notNull(), | |
| 311 | + operationKey: text("operation_key").notNull(), | |
| 312 | + transportKind: text("transport_kind").notNull(), | |
| 313 | + toolId: text("tool_id").notNull(), | |
| 314 | + title: text("title"), | |
| 315 | + description: text("description"), | |
| 316 | + operationKind: text("operation_kind").notNull(), | |
| 317 | + searchText: text("search_text").notNull(), | |
| 318 | + inputSchemaJson: text("input_schema_json"), | |
| 319 | + outputSchemaJson: text("output_schema_json"), | |
| 320 | + providerKind: text("provider_kind").notNull(), | |
| 321 | + providerDataJson: text("provider_data_json"), | |
| 322 | + mcpToolName: text("mcp_tool_name"), | |
| 323 | + openApiMethod: text("openapi_method"), | |
| 324 | + openApiPathTemplate: text("openapi_path_template"), | |
| 325 | + openApiOperationHash: text("openapi_operation_hash"), | |
| 326 | + openApiRawToolId: text("openapi_raw_tool_id"), | |
| 327 | + openApiOperationId: text("openapi_operation_id"), | |
| 328 | + openApiTagsJson: text("openapi_tags_json"), | |
| 329 | + openApiRequestBodyRequired: boolean("openapi_request_body_required"), | |
| 330 | + graphqlOperationType: text("graphql_operation_type"), | |
| 331 | + graphqlOperationName: text("graphql_operation_name"), | |
| 332 | + createdAt: bigint("created_at", { mode: "number" }).notNull(), | |
| 333 | + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | |
| 334 | + }, | |
| 335 | + (table) => [ | |
| 336 | + uniqueIndex("source_recipe_operations_revision_operation_key_idx").on( | |
| 337 | + table.recipeRevisionId, | |
| 338 | + table.operationKey, | |
| 339 | + ), | |
| 340 | + index("source_recipe_operations_revision_tool_idx").on( | |
| 341 | + table.recipeRevisionId, | |
| 342 | + table.toolId, | |
| 343 | + table.updatedAt, | |
| 344 | + table.id, | |
| 345 | + ), | |
| 346 | + index("source_recipe_operations_search_text_idx").using( | |
| 347 | + "gin", | |
| 348 | + sql`to_tsvector('simple', ${table.searchText})`, | |
| 349 | + ), | |
| 350 | + check( | |
| 351 | + "source_recipe_operations_transport_kind_check", | |
| 352 | + sql`${table.transportKind} in ('http', 'graphql', 'mcp', 'internal')`, | |
| 353 | + ), | |
| 354 | + check( | |
| 355 | + "source_recipe_operations_kind_check", | |
| 356 | + sql`${table.operationKind} in ('read', 'write', 'delete', 'unknown')`, | |
| 357 | + ), | |
| 358 | + check( | |
| 359 | + "source_recipe_operations_provider_kind_check", | |
| 360 | + sql`${table.providerKind} in ('mcp', 'openapi', 'graphql', 'internal')`, | |
| 361 | + ), | |
| 362 | + ], | |
| 363 | +); | |
| 364 | + | |
| 178 | 365 | export const credentialsTable = pgTable( |
| 179 | 366 | tableNames.credentials, |
| 180 | 367 | { |
| 181 | 368 | id: text("id").notNull().primaryKey(), |
| 182 | 369 | workspaceId: text("workspace_id").notNull(), |
| 370 | + sourceId: text("source_id").notNull(), | |
| 371 | + actorAccountId: text("actor_account_id"), | |
| 183 | 372 | authKind: text("auth_kind").notNull(), |
| 184 | 373 | authHeaderName: text("auth_header_name").notNull(), |
| 185 | 374 | authPrefix: text("auth_prefix").notNull(), |
| @@ -196,6 +385,17 @@ export const credentialsTable = pgTable( · 10 unmodified lines | ||
| 196 | 385 | table.updatedAt, |
| 197 | 386 | table.id, |
| 198 | 387 | ), |
| 388 | + uniqueIndex("credentials_workspace_source_actor_idx").on( | |
| 389 | + table.workspaceId, | |
| 390 | + table.sourceId, | |
| 391 | + table.actorAccountId, | |
| 392 | + ), | |
| 393 | + index("credentials_workspace_source_idx").on( | |
| 394 | + table.workspaceId, | |
| 395 | + table.sourceId, | |
| 396 | + table.updatedAt, | |
| 397 | + table.id, | |
| 398 | + ), | |
| 199 | 399 | check( |
| 200 | 400 | "credentials_auth_kind_check", |
| 201 | 401 | sql`${table.authKind} in ('bearer', 'oauth2')`, |
| @@ -203,22 +403,27 @@ export const credentialsTable = pgTable( · 1 unmodified line | ||
| 203 | 403 | ], |
| 204 | 404 | ); |
| 205 | 405 | |
| 206 | −export const sourceCredentialBindingsTable = pgTable( | |
| 207 | − tableNames.sourceCredentialBindings, | |
| 406 | +export const workspaceSourceOauthClientsTable = pgTable( | |
| 407 | + tableNames.workspaceSourceOauthClients, | |
| 208 | 408 | { |
| 209 | 409 | id: text("id").notNull().primaryKey(), |
| 210 | 410 | workspaceId: text("workspace_id").notNull(), |
| 211 | 411 | sourceId: text("source_id").notNull(), |
| 212 | − credentialId: text("credential_id").notNull(), | |
| 412 | + providerKey: text("provider_key").notNull(), | |
| 413 | + clientId: text("client_id").notNull(), | |
| 414 | + clientSecretProviderId: text("client_secret_provider_id"), | |
| 415 | + clientSecretHandle: text("client_secret_handle"), | |
| 416 | + clientMetadataJson: text("client_metadata_json"), | |
| 213 | 417 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 214 | 418 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 215 | 419 | }, |
| 216 | 420 | (table) => [ |
| 217 | − uniqueIndex("source_credential_bindings_workspace_source_idx").on( | |
| 421 | + uniqueIndex("workspace_source_oauth_clients_workspace_source_provider_idx").on( | |
| 218 | 422 | table.workspaceId, |
| 219 | 423 | table.sourceId, |
| 424 | + table.providerKey, | |
| 220 | 425 | ), |
| 221 | − index("source_credential_bindings_workspace_idx").on( | |
| 426 | + index("workspace_source_oauth_clients_workspace_idx").on( | |
| 222 | 427 | table.workspaceId, |
| 223 | 428 | table.updatedAt, |
| 224 | 429 | table.sourceId, |
| @@ -379,21 +584,13 @@ export const sourceAuthSessionsTable = pgTable( · 154 unmodified lines | ||
| 379 | 584 | id: text("id").notNull().primaryKey(), |
| 380 | 585 | workspaceId: text("workspace_id").notNull(), |
| 381 | 586 | sourceId: text("source_id").notNull(), |
| 587 | + actorAccountId: text("actor_account_id"), | |
| 382 | 588 | executionId: text("execution_id"), |
| 383 | 589 | interactionId: text("interaction_id"), |
| 384 | − strategy: text("strategy").notNull(), | |
| 590 | + providerKind: text("provider_kind").notNull(), | |
| 385 | 591 | status: text("status").notNull(), |
| 386 | − endpoint: text("endpoint").notNull(), | |
| 387 | 592 | state: text("state").notNull(), |
| 388 | − redirectUri: text("redirect_uri").notNull(), | |
| 389 | − scope: text("scope"), | |
| 390 | − resourceMetadataUrl: text("resource_metadata_url"), | |
| 391 | − authorizationServerUrl: text("authorization_server_url"), | |
| 392 | − resourceMetadataJson: text("resource_metadata_json"), | |
| 393 | − authorizationServerMetadataJson: text("authorization_server_metadata_json"), | |
| 394 | − clientInformationJson: text("client_information_json"), | |
| 395 | − codeVerifier: text("code_verifier"), | |
| 396 | − authorizationUrl: text("authorization_url"), | |
| 593 | + sessionDataJson: text("session_data_json").notNull(), | |
| 397 | 594 | errorText: text("error_text"), |
| 398 | 595 | completedAt: bigint("completed_at", { mode: "number" }), |
| 399 | 596 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| @@ -408,14 +605,15 @@ export const sourceAuthSessionsTable = pgTable( · 8 unmodified lines | ||
| 408 | 605 | index("source_auth_sessions_pending_idx").on( |
| 409 | 606 | table.workspaceId, |
| 410 | 607 | table.sourceId, |
| 608 | + table.actorAccountId, | |
| 411 | 609 | table.status, |
| 412 | 610 | table.updatedAt, |
| 413 | 611 | table.id, |
| 414 | 612 | ), |
| 415 | 613 | uniqueIndex("source_auth_sessions_state_idx").on(table.state), |
| 416 | 614 | check( |
| 417 | − "source_auth_sessions_strategy_check", | |
| 418 | − sql`${table.strategy} in ('oauth2_authorization_code')`, | |
| 615 | + "source_auth_sessions_provider_kind_check", | |
| 616 | + sql`${table.providerKind} in ('mcp_oauth', 'oauth2_pkce')`, | |
| 419 | 617 | ), |
| 420 | 618 | check( |
| 421 | 619 | "source_auth_sessions_status_check", |
| @@ -561,12 +759,16 @@ export const drizzleSchema = { · 139 unmodified lines | ||
| 561 | 759 | organizationMembershipsTable, |
| 562 | 760 | workspacesTable, |
| 563 | 761 | sourcesTable, |
| 762 | + sourceRecipesTable, | |
| 763 | + sourceRecipeRevisionsTable, | |
| 764 | + sourceRecipeDocumentsTable, | |
| 765 | + sourceRecipeOperationsTable, | |
| 564 | 766 | credentialsTable, |
| 767 | + workspaceSourceOauthClientsTable, | |
| 565 | 768 | toolArtifactsTable, |
| 566 | 769 | toolArtifactParametersTable, |
| 567 | 770 | toolArtifactRequestBodyContentTypesTable, |
| 568 | 771 | toolArtifactRefHintKeysTable, |
| 569 | − sourceCredentialBindingsTable, | |
| 570 | 772 | secretMaterialsTable, |
| 571 | 773 | sourceAuthSessionsTable, |
| 572 | 774 | policiesTable, |
| @@ -29,55 +29,51 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 28 unmodified lines | ||
| 29 | 29 | }); |
| 30 | 30 | |
| 31 | 31 | describe("source-auth-service", () => { |
| 32 | + const baseSessionDataJson = JSON.stringify({ | |
| 33 | + kind: "mcp_oauth", | |
| 34 | + endpoint: "https://example.com/resource", | |
| 35 | + redirectUri: "http://127.0.0.1/callback", | |
| 36 | + scope: null, | |
| 37 | + resourceMetadataUrl: "https://example.com/resource", | |
| 38 | + authorizationServerUrl: "https://example.com/as", | |
| 39 | + resourceMetadataJson: '{"issuer":"https://example.com"}', | |
| 40 | + authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}', | |
| 41 | + clientInformationJson: '{"client_id":"abc"}', | |
| 42 | + codeVerifier: "verifier", | |
| 43 | + authorizationUrl: "https://example.com/auth", | |
| 44 | + }); | |
| 45 | + | |
| 32 | 46 | it("clears ephemeral OAuth session fields when failing a session", () => { |
| 33 | 47 | const patch = createTerminalSourceAuthSessionPatch({ |
| 48 | + sessionDataJson: baseSessionDataJson, | |
| 34 | 49 | status: "failed", |
| 35 | 50 | now: 123, |
| 36 | 51 | errorText: "OAuth authorization failed", |
| 37 | − resourceMetadataUrl: "https://example.com/resource", | |
| 38 | − authorizationServerUrl: "https://example.com/as", | |
| 39 | − resourceMetadataJson: '{"issuer":"https://example.com"}', | |
| 40 | − authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}', | |
| 41 | 52 | }); |
| 42 | 53 | |
| 43 | 54 | expect(patch).toMatchObject({ |
| 55 | + sessionDataJson: baseSessionDataJson, | |
| 44 | 56 | status: "failed", |
| 45 | 57 | errorText: "OAuth authorization failed", |
| 46 | 58 | completedAt: 123, |
| 47 | 59 | updatedAt: 123, |
| 48 | − codeVerifier: null, | |
| 49 | − authorizationUrl: null, | |
| 50 | − clientInformationJson: null, | |
| 51 | − resourceMetadataUrl: "https://example.com/resource", | |
| 52 | − authorizationServerUrl: "https://example.com/as", | |
| 53 | − resourceMetadataJson: '{"issuer":"https://example.com"}', | |
| 54 | − authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}', | |
| 55 | 60 | }); |
| 56 | 61 | }); |
| 57 | 62 | |
| 58 | 63 | it("clears ephemeral OAuth session fields when completing a session", () => { |
| 59 | 64 | const patch = createTerminalSourceAuthSessionPatch({ |
| 65 | + sessionDataJson: baseSessionDataJson, | |
| 60 | 66 | status: "completed", |
| 61 | 67 | now: 456, |
| 62 | 68 | errorText: null, |
| 63 | − resourceMetadataUrl: "https://example.com/resource", | |
| 64 | − authorizationServerUrl: "https://example.com/as", | |
| 65 | − resourceMetadataJson: '{"issuer":"https://example.com"}', | |
| 66 | − authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}', | |
| 67 | 69 | }); |
| 68 | 70 | |
| 69 | 71 | expect(patch).toMatchObject({ |
| 72 | + sessionDataJson: baseSessionDataJson, | |
| 70 | 73 | status: "completed", |
| 71 | 74 | errorText: null, |
| 72 | 75 | completedAt: 456, |
| 73 | 76 | updatedAt: 456, |
| 74 | − codeVerifier: null, | |
| 75 | − authorizationUrl: null, | |
| 76 | − clientInformationJson: null, | |
| 77 | − resourceMetadataUrl: "https://example.com/resource", | |
| 78 | − authorizationServerUrl: "https://example.com/as", | |
| 79 | − resourceMetadataJson: '{"issuer":"https://example.com"}', | |
| 80 | − authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}', | |
| 81 | 77 | }); |
| 82 | 78 | }); |
| 83 | 79 | |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type SqlControlPlaneRows, |
| 9 | 9 | } from "#persistence"; |
| 10 | 10 | import { |
| 11 | + AccountId, | |
| 11 | 12 | ExecutionIdSchema, |
| 12 | 13 | type SecretMaterialPurpose, |
| 13 | 14 | Source, |
| @@ -163,27 +164,74 @@ const probeMcpSourceWithoutAuth = ( · 149 unmodified lines | ||
| 163 | 164 | }); |
| 164 | 165 | |
| 165 | 166 | export const createTerminalSourceAuthSessionPatch = (input: { |
| 167 | + sessionDataJson: string; | |
| 166 | 168 | status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">; |
| 167 | 169 | now: number; |
| 168 | 170 | errorText: string | null; |
| 169 | − resourceMetadataUrl?: string | null; | |
| 170 | − authorizationServerUrl?: string | null; | |
| 171 | − resourceMetadataJson?: string | null; | |
| 172 | − authorizationServerMetadataJson?: string | null; | |
| 173 | 171 | }) => ({ |
| 174 | 172 | status: input.status, |
| 175 | 173 | errorText: input.errorText, |
| 176 | 174 | completedAt: input.now, |
| 177 | 175 | updatedAt: input.now, |
| 178 | − codeVerifier: null, | |
| 179 | − authorizationUrl: null, | |
| 180 | − clientInformationJson: null, | |
| 181 | − resourceMetadataUrl: input.resourceMetadataUrl ?? null, | |
| 182 | − authorizationServerUrl: input.authorizationServerUrl ?? null, | |
| 183 | − resourceMetadataJson: input.resourceMetadataJson ?? null, | |
| 184 | − authorizationServerMetadataJson: input.authorizationServerMetadataJson ?? null, | |
| 176 | + sessionDataJson: input.sessionDataJson, | |
| 185 | 177 | }) satisfies Partial<SourceAuthSession>; |
| 186 | 178 | |
| 179 | +type McpSourceAuthSessionData = { | |
| 180 | + kind: "mcp_oauth"; | |
| 181 | + endpoint: string; | |
| 182 | + redirectUri: string; | |
| 183 | + scope: string | null; | |
| 184 | + resourceMetadataUrl: string | null; | |
| 185 | + authorizationServerUrl: string | null; | |
| 186 | + resourceMetadataJson: string | null; | |
| 187 | + authorizationServerMetadataJson: string | null; | |
| 188 | + clientInformationJson: string | null; | |
| 189 | + codeVerifier: string | null; | |
| 190 | + authorizationUrl: string | null; | |
| 191 | +}; | |
| 192 | + | |
| 193 | +const encodeMcpSourceAuthSessionData = ( | |
| 194 | + sessionData: McpSourceAuthSessionData, | |
| 195 | +): string => JSON.stringify(sessionData); | |
| 196 | + | |
| 197 | +const decodeMcpSourceAuthSessionData = ( | |
| 198 | + session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">, | |
| 199 | +): McpSourceAuthSessionData => { | |
| 200 | + if (session.providerKind !== "mcp_oauth") { | |
| 201 | + throw new Error(`Unsupported source auth provider for session ${session.id}`); | |
| 202 | + } | |
| 203 | + | |
| 204 | + let parsed: unknown; | |
| 205 | + try { | |
| 206 | + parsed = JSON.parse(session.sessionDataJson) as unknown; | |
| 207 | + } catch (cause) { | |
| 208 | + throw new Error( | |
| 209 | + `Invalid source auth session data for ${session.id}: ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 210 | + ); | |
| 211 | + } | |
| 212 | + | |
| 213 | + if ( | |
| 214 | + parsed === null | |
| 215 | + || typeof parsed !== "object" | |
| 216 | + || (parsed as { kind?: unknown }).kind !== "mcp_oauth" | |
| 217 | + ) { | |
| 218 | + throw new Error(`Invalid source auth session payload for ${session.id}`); | |
| 219 | + } | |
| 220 | + | |
| 221 | + return parsed as McpSourceAuthSessionData; | |
| 222 | +}; | |
| 223 | + | |
| 224 | +const mergeMcpSourceAuthSessionData = (input: { | |
| 225 | + session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">; | |
| 226 | + patch: Partial<McpSourceAuthSessionData>; | |
| 227 | +}): string => { | |
| 228 | + const existing = decodeMcpSourceAuthSessionData(input.session); | |
| 229 | + return encodeMcpSourceAuthSessionData({ | |
| 230 | + ...existing, | |
| 231 | + ...input.patch, | |
| 232 | + }); | |
| 233 | +}; | |
| 234 | + | |
| 187 | 235 | |
| 188 | 236 | const completeLiveInteraction = (input: { |
| 189 | 237 | liveExecutionManager: LiveExecutionManager; |
| @@ -217,6 +265,7 @@ const completeLiveInteraction = (input: { · 27 unmodified lines | ||
| 217 | 265 | }); |
| 218 | 266 | |
| 219 | 267 | const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { |
| 268 | + actorAccountId?: AccountId | null; | |
| 220 | 269 | status: Source["status"]; |
| 221 | 270 | lastError?: string | null; |
| 222 | 271 | auth?: Source["auth"]; |
| @@ -225,6 +274,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines | ||
| 225 | 274 | const latest = yield* loadSourceById(rows, { |
| 226 | 275 | workspaceId: source.workspaceId, |
| 227 | 276 | sourceId: source.id, |
| 277 | + actorAccountId: input.actorAccountId, | |
| 228 | 278 | }); |
| 229 | 279 | |
| 230 | 280 | return yield* persistSource(rows, { |
| @@ -233,6 +283,8 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines | ||
| 233 | 283 | lastError: input.lastError ?? null, |
| 234 | 284 | auth: input.auth ?? latest.auth, |
| 235 | 285 | updatedAt: Date.now(), |
| 286 | + }, { | |
| 287 | + actorAccountId: input.actorAccountId, | |
| 236 | 288 | }); |
| 237 | 289 | }); |
| 238 | 290 | |
| @@ -277,6 +329,7 @@ export type ExecutorAddSourceInput = · 38 unmodified lines | ||
| 277 | 329 | | { |
| 278 | 330 | kind?: "mcp"; |
| 279 | 331 | workspaceId: WorkspaceId; |
| 332 | + actorAccountId?: AccountId | null; | |
| 280 | 333 | executionId: SourceAuthSession["executionId"]; |
| 281 | 334 | interactionId: SourceAuthSession["interactionId"]; |
| 282 | 335 | endpoint: string; |
| @@ -286,6 +339,7 @@ export type ExecutorAddSourceInput = · 3 unmodified lines | ||
| 286 | 339 | | { |
| 287 | 340 | kind: "openapi"; |
| 288 | 341 | workspaceId: WorkspaceId; |
| 342 | + actorAccountId?: AccountId | null; | |
| 289 | 343 | executionId: SourceAuthSession["executionId"]; |
| 290 | 344 | interactionId: SourceAuthSession["interactionId"]; |
| 291 | 345 | endpoint: string; |
| @@ -297,6 +351,7 @@ export type ExecutorAddSourceInput = · 5 unmodified lines | ||
| 297 | 351 | | { |
| 298 | 352 | kind: "graphql"; |
| 299 | 353 | workspaceId: WorkspaceId; |
| 354 | + actorAccountId?: AccountId | null; | |
| 300 | 355 | executionId: SourceAuthSession["executionId"]; |
| 301 | 356 | interactionId: SourceAuthSession["interactionId"]; |
| 302 | 357 | endpoint: string; |
| @@ -307,6 +362,7 @@ export type ExecutorAddSourceInput = · 4 unmodified lines | ||
| 307 | 362 | |
| 308 | 363 | export type ConnectMcpSourceInput = { |
| 309 | 364 | workspaceId: WorkspaceId; |
| 365 | + actorAccountId?: AccountId | null; | |
| 310 | 366 | sourceId?: Source["id"] | null; |
| 311 | 367 | endpoint: string; |
| 312 | 368 | name?: string | null; |
| @@ -332,6 +388,7 @@ export type SourceOAuthProviderInput = { · 19 unmodified lines | ||
| 332 | 388 | |
| 333 | 389 | export type StartSourceOAuthSessionInput = { |
| 334 | 390 | workspaceId: WorkspaceId; |
| 391 | + actorAccountId?: AccountId | null; | |
| 335 | 392 | provider: SourceOAuthProviderInput; |
| 336 | 393 | baseUrl?: string | null; |
| 337 | 394 | displayName?: string | null; |
| @@ -482,6 +539,7 @@ const connectMcpSourceInternal = (input: { · 144 unmodified lines | ||
| 482 | 539 | getLocalServerBaseUrl?: () => string | undefined; |
| 483 | 540 | baseUrl?: string | null; |
| 484 | 541 | workspaceId: WorkspaceId; |
| 542 | + actorAccountId?: AccountId | null; | |
| 485 | 543 | sourceId?: Source["id"] | null; |
| 486 | 544 | executionId?: SourceAuthSession["executionId"]; |
| 487 | 545 | interactionId?: SourceAuthSession["interactionId"]; |
| @@ -502,6 +560,7 @@ const connectMcpSourceInternal = (input: { · 14 unmodified lines | ||
| 502 | 560 | ? loadSourceById(input.rows, { |
| 503 | 561 | workspaceId: input.workspaceId, |
| 504 | 562 | sourceId: input.sourceId, |
| 563 | + actorAccountId: input.actorAccountId, | |
| 505 | 564 | }).pipe( |
| 506 | 565 | Effect.flatMap((source) => |
| 507 | 566 | source.kind === "mcp" |
| @@ -509,7 +568,9 @@ const connectMcpSourceInternal = (input: { · 1 unmodified line | ||
| 509 | 568 | : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)), |
| 510 | 569 | ), |
| 511 | 570 | ) |
| 512 | − : loadSourcesInWorkspace(input.rows, input.workspaceId).pipe( | |
| 571 | + : loadSourcesInWorkspace(input.rows, input.workspaceId, { | |
| 572 | + actorAccountId: input.actorAccountId, | |
| 573 | + }).pipe( | |
| 513 | 574 | Effect.map((sources) => |
| 514 | 575 | sources.find( |
| 515 | 576 | (source) => |
| @@ -569,7 +630,9 @@ const connectMcpSourceInternal = (input: { · 53 unmodified lines | ||
| 569 | 630 | now, |
| 570 | 631 | }); |
| 571 | 632 | |
| 572 | − const persistedDraft = yield* persistSource(input.rows, draftSource); | |
| 633 | + const persistedDraft = yield* persistSource(input.rows, draftSource, { | |
| 634 | + actorAccountId: input.actorAccountId, | |
| 635 | + }); | |
| 573 | 636 | yield* syncSourceToolArtifacts({ |
| 574 | 637 | rows: input.rows, |
| 575 | 638 | source: persistedDraft, |
| @@ -588,6 +651,7 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines | ||
| 588 | 651 | onRight: (result) => |
| 589 | 652 | Effect.gen(function* () { |
| 590 | 653 | const connected = yield* updateSourceStatus(input.rows, persistedDraft, { |
| 654 | + actorAccountId: input.actorAccountId, | |
| 591 | 655 | status: "connected", |
| 592 | 656 | lastError: null, |
| 593 | 657 | auth: { kind: "none" }, |
| @@ -603,6 +667,7 @@ const connectMcpSourceInternal = (input: { · 9 unmodified lines | ||
| 603 | 667 | return yield* Either.match(indexed, { |
| 604 | 668 | onLeft: (error) => |
| 605 | 669 | updateSourceStatus(input.rows, connected, { |
| 670 | + actorAccountId: input.actorAccountId, | |
| 606 | 671 | status: "error", |
| 607 | 672 | lastError: error.message, |
| 608 | 673 | }).pipe( |
| @@ -642,6 +707,7 @@ const connectMcpSourceInternal = (input: { · 33 unmodified lines | ||
| 642 | 707 | }); |
| 643 | 708 | |
| 644 | 709 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { |
| 710 | + actorAccountId: input.actorAccountId, | |
| 645 | 711 | status: "auth_required", |
| 646 | 712 | lastError: null, |
| 647 | 713 | }); |
| @@ -651,21 +717,25 @@ const connectMcpSourceInternal = (input: { · 3 unmodified lines | ||
| 651 | 717 | id: sessionId, |
| 652 | 718 | workspaceId: input.workspaceId, |
| 653 | 719 | sourceId: authRequiredSource.id, |
| 720 | + actorAccountId: input.actorAccountId ?? null, | |
| 654 | 721 | executionId: input.executionId ?? null, |
| 655 | 722 | interactionId: input.interactionId ?? null, |
| 656 | − strategy: "oauth2_authorization_code", | |
| 723 | + providerKind: "mcp_oauth", | |
| 657 | 724 | status: "pending", |
| 658 | − endpoint: normalizedEndpoint, | |
| 659 | 725 | state, |
| 660 | − redirectUri: redirectUrl, | |
| 661 | − scope: null, | |
| 662 | − resourceMetadataUrl: oauthStart.resourceMetadataUrl, | |
| 663 | − authorizationServerUrl: oauthStart.authorizationServerUrl, | |
| 664 | − resourceMetadataJson: oauthStart.resourceMetadataJson, | |
| 665 | − authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, | |
| 666 | − clientInformationJson: oauthStart.clientInformationJson, | |
| 667 | − codeVerifier: oauthStart.codeVerifier, | |
| 668 | − authorizationUrl: oauthStart.authorizationUrl, | |
| 726 | + sessionDataJson: encodeMcpSourceAuthSessionData({ | |
| 727 | + kind: "mcp_oauth", | |
| 728 | + endpoint: normalizedEndpoint, | |
| 729 | + redirectUri: redirectUrl, | |
| 730 | + scope: null, | |
| 731 | + resourceMetadataUrl: oauthStart.resourceMetadataUrl, | |
| 732 | + authorizationServerUrl: oauthStart.authorizationServerUrl, | |
| 733 | + resourceMetadataJson: oauthStart.resourceMetadataJson, | |
| 734 | + authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, | |
| 735 | + clientInformationJson: oauthStart.clientInformationJson, | |
| 736 | + codeVerifier: oauthStart.codeVerifier, | |
| 737 | + authorizationUrl: oauthStart.authorizationUrl, | |
| 738 | + }), | |
| 669 | 739 | errorText: null, |
| 670 | 740 | completedAt: null, |
| 671 | 741 | createdAt: sessionNow, |
| @@ -694,6 +764,9 @@ const addExecutorHttpSource = (input: { · 22 unmodified lines | ||
| 694 | 764 | const existingSources = yield* loadSourcesInWorkspace( |
| 695 | 765 | input.rows, |
| 696 | 766 | input.sourceInput.workspaceId, |
| 767 | + { | |
| 768 | + actorAccountId: input.sourceInput.actorAccountId, | |
| 769 | + }, | |
| 697 | 770 | ); |
| 698 | 771 | const existing = existingSources.find((source) => { |
| 699 | 772 | if (source.kind !== input.sourceInput.kind) { |
| @@ -757,7 +830,9 @@ const addExecutorHttpSource = (input: { · 57 unmodified lines | ||
| 757 | 830 | now, |
| 758 | 831 | }); |
| 759 | 832 | |
| 760 | − const persistedDraft = yield* persistSource(input.rows, draftSource); | |
| 833 | + const persistedDraft = yield* persistSource(input.rows, draftSource, { | |
| 834 | + actorAccountId: input.sourceInput.actorAccountId, | |
| 835 | + }); | |
| 761 | 836 | return { |
| 762 | 837 | kind: "credential_required", |
| 763 | 838 | source: persistedDraft, |
| @@ -802,7 +877,9 @@ const addExecutorHttpSource = (input: { · 38 unmodified lines | ||
| 802 | 877 | now, |
| 803 | 878 | }); |
| 804 | 879 | |
| 805 | − const persistedDraft = yield* persistSource(input.rows, draftSource); | |
| 880 | + const persistedDraft = yield* persistSource(input.rows, draftSource, { | |
| 881 | + actorAccountId: input.sourceInput.actorAccountId, | |
| 882 | + }); | |
| 806 | 883 | const synced = yield* Effect.either( |
| 807 | 884 | syncSourceToolArtifacts({ |
| 808 | 885 | rows: input.rows, |
| @@ -817,6 +894,7 @@ const addExecutorHttpSource = (input: { · 8 unmodified lines | ||
| 817 | 894 | return yield* Either.match(synced, { |
| 818 | 895 | onLeft: (error) => |
| 819 | 896 | updateSourceStatus(input.rows, persistedDraft, { |
| 897 | + actorAccountId: input.sourceInput.actorAccountId, | |
| 820 | 898 | status: "error", |
| 821 | 899 | lastError: error.message, |
| 822 | 900 | }).pipe( |
| @@ -824,6 +902,7 @@ const addExecutorHttpSource = (input: { · 1 unmodified line | ||
| 824 | 902 | ), |
| 825 | 903 | onRight: () => |
| 826 | 904 | updateSourceStatus(input.rows, persistedDraft, { |
| 905 | + actorAccountId: input.sourceInput.actorAccountId, | |
| 827 | 906 | status: "connected", |
| 828 | 907 | lastError: null, |
| 829 | 908 | }).pipe( |
| @@ -841,6 +920,7 @@ type RuntimeSourceAuthServiceShape = { · 11 unmodified lines | ||
| 841 | 920 | getSourceById: (input: { |
| 842 | 921 | workspaceId: WorkspaceId; |
| 843 | 922 | sourceId: Source["id"]; |
| 923 | + actorAccountId?: AccountId | null; | |
| 844 | 924 | }) => Effect.Effect<Source, Error, never>; |
| 845 | 925 | getLocalServerBaseUrl: () => string | null; |
| 846 | 926 | storeSecretMaterial: (input: { |
| @@ -869,6 +949,7 @@ type RuntimeSourceAuthServiceShape = { · 22 unmodified lines | ||
| 869 | 949 | completeSourceCredentialSetup: (input: { |
| 870 | 950 | workspaceId: WorkspaceId; |
| 871 | 951 | sourceId: Source["id"]; |
| 952 | + actorAccountId?: AccountId | null; | |
| 872 | 953 | state: string; |
| 873 | 954 | code?: string | null; |
| 874 | 955 | error?: string | null; |
| @@ -897,10 +978,11 @@ export const createRuntimeSourceAuthService = (input: { · 22 unmodified lines | ||
| 897 | 978 | value, |
| 898 | 979 | }), |
| 899 | 980 | |
| 900 | − getSourceById: ({ workspaceId, sourceId }) => | |
| 981 | + getSourceById: ({ workspaceId, sourceId, actorAccountId }) => | |
| 901 | 982 | loadSourceById(input.rows, { |
| 902 | 983 | workspaceId, |
| 903 | 984 | sourceId, |
| 985 | + actorAccountId, | |
| 904 | 986 | }), |
| 905 | 987 | |
| 906 | 988 | addExecutorSource: (sourceInput, options) => |
| @@ -915,6 +997,7 @@ export const createRuntimeSourceAuthService = (input: { · 8 unmodified lines | ||
| 915 | 997 | rows: input.rows, |
| 916 | 998 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 917 | 999 | workspaceId: sourceInput.workspaceId, |
| 1000 | + actorAccountId: sourceInput.actorAccountId, | |
| 918 | 1001 | executionId: sourceInput.executionId, |
| 919 | 1002 | interactionId: sourceInput.interactionId, |
| 920 | 1003 | endpoint: sourceInput.endpoint, |
| @@ -930,6 +1013,7 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines | ||
| 930 | 1013 | rows: input.rows, |
| 931 | 1014 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 932 | 1015 | workspaceId: sourceInput.workspaceId, |
| 1016 | + actorAccountId: sourceInput.actorAccountId, | |
| 933 | 1017 | sourceId: sourceInput.sourceId, |
| 934 | 1018 | executionId: null, |
| 935 | 1019 | interactionId: null, |
| @@ -972,21 +1056,25 @@ export const createRuntimeSourceAuthService = (input: { · 36 unmodified lines | ||
| 972 | 1056 | id: sessionId, |
| 973 | 1057 | workspaceId: oauthInput.workspaceId, |
| 974 | 1058 | sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`), |
| 1059 | + actorAccountId: oauthInput.actorAccountId ?? null, | |
| 975 | 1060 | executionId: null, |
| 976 | 1061 | interactionId: null, |
| 977 | − strategy: "oauth2_authorization_code", | |
| 1062 | + providerKind: "mcp_oauth", | |
| 978 | 1063 | status: "pending", |
| 979 | − endpoint, | |
| 980 | 1064 | state, |
| 981 | − redirectUri: redirectUrl, | |
| 982 | − scope: oauthInput.provider.kind, | |
| 983 | − resourceMetadataUrl: oauthStart.resourceMetadataUrl, | |
| 984 | − authorizationServerUrl: oauthStart.authorizationServerUrl, | |
| 985 | − resourceMetadataJson: oauthStart.resourceMetadataJson, | |
| 986 | − authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, | |
| 987 | − clientInformationJson: oauthStart.clientInformationJson, | |
| 988 | − codeVerifier: oauthStart.codeVerifier, | |
| 989 | − authorizationUrl: oauthStart.authorizationUrl, | |
| 1065 | + sessionDataJson: encodeMcpSourceAuthSessionData({ | |
| 1066 | + kind: "mcp_oauth", | |
| 1067 | + endpoint, | |
| 1068 | + redirectUri: redirectUrl, | |
| 1069 | + scope: oauthInput.provider.kind, | |
| 1070 | + resourceMetadataUrl: oauthStart.resourceMetadataUrl, | |
| 1071 | + authorizationServerUrl: oauthStart.authorizationServerUrl, | |
| 1072 | + resourceMetadataJson: oauthStart.resourceMetadataJson, | |
| 1073 | + authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, | |
| 1074 | + clientInformationJson: oauthStart.clientInformationJson, | |
| 1075 | + codeVerifier: oauthStart.codeVerifier, | |
| 1076 | + authorizationUrl: oauthStart.authorizationUrl, | |
| 1077 | + }), | |
| 990 | 1078 | errorText: null, |
| 991 | 1079 | completedAt: null, |
| 992 | 1080 | createdAt: now, |
| @@ -1012,6 +1100,7 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines | ||
| 1012 | 1100 | } |
| 1013 | 1101 | |
| 1014 | 1102 | const session = sessionOption.value; |
| 1103 | + const sessionData = decodeMcpSourceAuthSessionData(session); | |
| 1015 | 1104 | if (session.status === "completed") { |
| 1016 | 1105 | return yield* Effect.fail(new Error(`Source auth session ${session.id} is already completed`)); |
| 1017 | 1106 | } |
| @@ -1027,13 +1116,10 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines | ||
| 1027 | 1116 | yield* input.rows.sourceAuthSessions.update( |
| 1028 | 1117 | session.id, |
| 1029 | 1118 | createTerminalSourceAuthSessionPatch({ |
| 1119 | + sessionDataJson: session.sessionDataJson, | |
| 1030 | 1120 | status: "failed", |
| 1031 | 1121 | now: failedAt, |
| 1032 | 1122 | errorText: reason, |
| 1033 | − resourceMetadataUrl: session.resourceMetadataUrl, | |
| 1034 | − authorizationServerUrl: session.authorizationServerUrl, | |
| 1035 | − resourceMetadataJson: session.resourceMetadataJson, | |
| 1036 | − authorizationServerMetadataJson: session.authorizationServerMetadataJson, | |
| 1037 | 1123 | }), |
| 1038 | 1124 | ); |
| 1039 | 1125 | |
| @@ -1045,31 +1131,31 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines | ||
| 1045 | 1131 | return yield* Effect.fail(new Error("Missing OAuth authorization code")); |
| 1046 | 1132 | } |
| 1047 | 1133 | |
| 1048 | − if (session.codeVerifier === null) { | |
| 1134 | + if (sessionData.codeVerifier === null) { | |
| 1049 | 1135 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); |
| 1050 | 1136 | } |
| 1051 | 1137 | |
| 1052 | − if (session.scope !== null && session.scope !== "mcp") { | |
| 1053 | − return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${session.scope}`)); | |
| 1138 | + if (sessionData.scope !== null && sessionData.scope !== "mcp") { | |
| 1139 | + return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${sessionData.scope}`)); | |
| 1054 | 1140 | } |
| 1055 | 1141 | |
| 1056 | 1142 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ |
| 1057 | 1143 | session: { |
| 1058 | − endpoint: session.endpoint, | |
| 1059 | − redirectUrl: session.redirectUri, | |
| 1060 | − codeVerifier: session.codeVerifier, | |
| 1061 | − resourceMetadataUrl: session.resourceMetadataUrl, | |
| 1062 | − authorizationServerUrl: session.authorizationServerUrl, | |
| 1063 | − resourceMetadataJson: session.resourceMetadataJson, | |
| 1064 | − authorizationServerMetadataJson: session.authorizationServerMetadataJson, | |
| 1065 | − clientInformationJson: session.clientInformationJson, | |
| 1144 | + endpoint: sessionData.endpoint, | |
| 1145 | + redirectUrl: sessionData.redirectUri, | |
| 1146 | + codeVerifier: sessionData.codeVerifier, | |
| 1147 | + resourceMetadataUrl: sessionData.resourceMetadataUrl, | |
| 1148 | + authorizationServerUrl: sessionData.authorizationServerUrl, | |
| 1149 | + resourceMetadataJson: sessionData.resourceMetadataJson, | |
| 1150 | + authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson, | |
| 1151 | + clientInformationJson: sessionData.clientInformationJson, | |
| 1066 | 1152 | }, |
| 1067 | 1153 | code: authorizationCode, |
| 1068 | 1154 | }); |
| 1069 | 1155 | |
| 1070 | 1156 | const oauthSecretName = resolveSourceOAuthSecretName({ |
| 1071 | 1157 | displayName: readSourceOAuthSessionDisplayName(session.state), |
| 1072 | − endpoint: session.endpoint, | |
| 1158 | + endpoint: sessionData.endpoint, | |
| 1073 | 1159 | }); |
| 1074 | 1160 | const accessTokenRef = yield* storeSecretMaterial({ |
| 1075 | 1161 | purpose: "oauth_access_token", |
| @@ -1095,13 +1181,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines | ||
| 1095 | 1181 | yield* input.rows.sourceAuthSessions.update( |
| 1096 | 1182 | session.id, |
| 1097 | 1183 | createTerminalSourceAuthSessionPatch({ |
| 1184 | + sessionDataJson: mergeMcpSourceAuthSessionData({ | |
| 1185 | + session, | |
| 1186 | + patch: { | |
| 1187 | + codeVerifier: null, | |
| 1188 | + authorizationUrl: null, | |
| 1189 | + resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1190 | + authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 1191 | + resourceMetadataJson: exchanged.resourceMetadataJson, | |
| 1192 | + authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson, | |
| 1193 | + }, | |
| 1194 | + }), | |
| 1098 | 1195 | status: "completed", |
| 1099 | 1196 | now: Date.now(), |
| 1100 | 1197 | errorText: null, |
| 1101 | − resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1102 | − authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 1103 | − resourceMetadataJson: exchanged.resourceMetadataJson, | |
| 1104 | − authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson, | |
| 1105 | 1198 | }), |
| 1106 | 1199 | ); |
| 1107 | 1200 | |
| @@ -1114,6 +1207,7 @@ export const createRuntimeSourceAuthService = (input: { · 6 unmodified lines | ||
| 1114 | 1207 | completeSourceCredentialSetup: ({ |
| 1115 | 1208 | workspaceId, |
| 1116 | 1209 | sourceId, |
| 1210 | + actorAccountId, | |
| 1117 | 1211 | state, |
| 1118 | 1212 | code, |
| 1119 | 1213 | error, |
| @@ -1126,6 +1220,7 @@ export const createRuntimeSourceAuthService = (input: { · 6 unmodified lines | ||
| 1126 | 1220 | } |
| 1127 | 1221 | |
| 1128 | 1222 | const session = sessionOption.value; |
| 1223 | + const sessionData = decodeMcpSourceAuthSessionData(session); | |
| 1129 | 1224 | if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { |
| 1130 | 1225 | return yield* Effect.fail( |
| 1131 | 1226 | new Error( |
| @@ -1133,10 +1228,19 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line | ||
| 1133 | 1228 | ), |
| 1134 | 1229 | ); |
| 1135 | 1230 | } |
| 1231 | + if ( | |
| 1232 | + actorAccountId !== undefined | |
| 1233 | + && (session.actorAccountId ?? null) !== (actorAccountId ?? null) | |
| 1234 | + ) { | |
| 1235 | + return yield* Effect.fail( | |
| 1236 | + new Error(`Source auth session ${session.id} does not match the active account`), | |
| 1237 | + ); | |
| 1238 | + } | |
| 1136 | 1239 | |
| 1137 | 1240 | const source = yield* loadSourceById(input.rows, { |
| 1138 | 1241 | workspaceId: session.workspaceId, |
| 1139 | 1242 | sourceId: session.sourceId, |
| 1243 | + actorAccountId: session.actorAccountId, | |
| 1140 | 1244 | }); |
| 1141 | 1245 | |
| 1142 | 1246 | if (session.status === "completed") { |
| @@ -1156,16 +1260,14 @@ export const createRuntimeSourceAuthService = (input: { · 13 unmodified lines | ||
| 1156 | 1260 | yield* input.rows.sourceAuthSessions.update( |
| 1157 | 1261 | session.id, |
| 1158 | 1262 | createTerminalSourceAuthSessionPatch({ |
| 1263 | + sessionDataJson: session.sessionDataJson, | |
| 1159 | 1264 | status: "failed", |
| 1160 | 1265 | now: failedAt, |
| 1161 | 1266 | errorText: reason, |
| 1162 | − resourceMetadataUrl: session.resourceMetadataUrl, | |
| 1163 | − authorizationServerUrl: session.authorizationServerUrl, | |
| 1164 | − resourceMetadataJson: session.resourceMetadataJson, | |
| 1165 | − authorizationServerMetadataJson: session.authorizationServerMetadataJson, | |
| 1166 | 1267 | }), |
| 1167 | 1268 | ); |
| 1168 | 1269 | const failedSource = yield* updateSourceStatus(input.rows, source, { |
| 1270 | + actorAccountId: session.actorAccountId, | |
| 1169 | 1271 | status: "error", |
| 1170 | 1272 | lastError: reason, |
| 1171 | 1273 | }); |
| @@ -1191,20 +1293,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines | ||
| 1191 | 1293 | return yield* Effect.fail(new Error("Missing OAuth authorization code")); |
| 1192 | 1294 | } |
| 1193 | 1295 | |
| 1194 | − if (session.codeVerifier === null) { | |
| 1296 | + if (sessionData.codeVerifier === null) { | |
| 1195 | 1297 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); |
| 1196 | 1298 | } |
| 1197 | 1299 | |
| 1198 | 1300 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ |
| 1199 | 1301 | session: { |
| 1200 | − endpoint: session.endpoint, | |
| 1201 | − redirectUrl: session.redirectUri, | |
| 1202 | − codeVerifier: session.codeVerifier, | |
| 1203 | − resourceMetadataUrl: session.resourceMetadataUrl, | |
| 1204 | − authorizationServerUrl: session.authorizationServerUrl, | |
| 1205 | − resourceMetadataJson: session.resourceMetadataJson, | |
| 1206 | − authorizationServerMetadataJson: session.authorizationServerMetadataJson, | |
| 1207 | − clientInformationJson: session.clientInformationJson, | |
| 1302 | + endpoint: sessionData.endpoint, | |
| 1303 | + redirectUrl: sessionData.redirectUri, | |
| 1304 | + codeVerifier: sessionData.codeVerifier, | |
| 1305 | + resourceMetadataUrl: sessionData.resourceMetadataUrl, | |
| 1306 | + authorizationServerUrl: sessionData.authorizationServerUrl, | |
| 1307 | + resourceMetadataJson: sessionData.resourceMetadataJson, | |
| 1308 | + authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson, | |
| 1309 | + clientInformationJson: sessionData.clientInformationJson, | |
| 1208 | 1310 | }, |
| 1209 | 1311 | code: authorizationCode, |
| 1210 | 1312 | }); |
| @@ -1228,6 +1330,7 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines | ||
| 1228 | 1330 | |
| 1229 | 1331 | const now = Date.now(); |
| 1230 | 1332 | const connectedSource = yield* updateSourceStatus(input.rows, source, { |
| 1333 | + actorAccountId: session.actorAccountId, | |
| 1231 | 1334 | status: "connected", |
| 1232 | 1335 | lastError: null, |
| 1233 | 1336 | auth: { |
| @@ -1248,6 +1351,7 @@ export const createRuntimeSourceAuthService = (input: { · 14 unmodified lines | ||
| 1248 | 1351 | yield* Either.match(indexed, { |
| 1249 | 1352 | onLeft: (error) => |
| 1250 | 1353 | updateSourceStatus(input.rows, connectedSource, { |
| 1354 | + actorAccountId: session.actorAccountId, | |
| 1251 | 1355 | status: "error", |
| 1252 | 1356 | lastError: error.message, |
| 1253 | 1357 | }).pipe( |
| @@ -1259,13 +1363,20 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines | ||
| 1259 | 1363 | yield* input.rows.sourceAuthSessions.update( |
| 1260 | 1364 | session.id, |
| 1261 | 1365 | createTerminalSourceAuthSessionPatch({ |
| 1366 | + sessionDataJson: mergeMcpSourceAuthSessionData({ | |
| 1367 | + session, | |
| 1368 | + patch: { | |
| 1369 | + codeVerifier: null, | |
| 1370 | + authorizationUrl: null, | |
| 1371 | + resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1372 | + authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 1373 | + resourceMetadataJson: exchanged.resourceMetadataJson, | |
| 1374 | + authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson, | |
| 1375 | + }, | |
| 1376 | + }), | |
| 1262 | 1377 | status: "completed", |
| 1263 | 1378 | now, |
| 1264 | 1379 | errorText: null, |
| 1265 | − resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1266 | − authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 1267 | − resourceMetadataJson: exchanged.resourceMetadataJson, | |
| 1268 | − authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson, | |
| 1269 | 1380 | }), |
| 1270 | 1381 | ); |
| 1271 | 1382 | |
| @@ -0,0 +1,435 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import * as Effect from "effect/Effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + CredentialIdSchema, | |
| 6 | + SourceIdSchema, | |
| 7 | + WorkspaceIdSchema, | |
| 8 | + type Source, | |
| 9 | +} from "#schema"; | |
| 10 | + | |
| 11 | +import { | |
| 12 | + createSourceFromPayload, | |
| 13 | + projectSourceFromStorage, | |
| 14 | + splitSourceForStorage, | |
| 15 | + stableSourceRecipeId, | |
| 16 | + stableSourceRecipeRevisionId, | |
| 17 | + updateSourceFromPayload, | |
| 18 | +} from "./source-definitions"; | |
| 19 | +import { namespaceFromSourceName } from "./tool-artifacts"; | |
| 20 | + | |
| 21 | +const makeSource = (overrides: Partial<Source> = {}): Source => ({ | |
| 22 | + id: SourceIdSchema.make("src_source_definitions"), | |
| 23 | + workspaceId: WorkspaceIdSchema.make("ws_source_definitions"), | |
| 24 | + name: "GitHub", | |
| 25 | + kind: "openapi", | |
| 26 | + endpoint: "https://api.github.com", | |
| 27 | + status: "connected", | |
| 28 | + enabled: true, | |
| 29 | + namespace: "github", | |
| 30 | + transport: null, | |
| 31 | + queryParams: null, | |
| 32 | + headers: null, | |
| 33 | + specUrl: "https://api.github.com/openapi.json", | |
| 34 | + defaultHeaders: null, | |
| 35 | + auth: { kind: "none" }, | |
| 36 | + sourceHash: null, | |
| 37 | + lastError: null, | |
| 38 | + createdAt: 1000, | |
| 39 | + updatedAt: 1000, | |
| 40 | + ...overrides, | |
| 41 | +}); | |
| 42 | + | |
| 43 | +describe("source-definitions", () => { | |
| 44 | + describe("stable recipe ids", () => { | |
| 45 | + it("is deterministic across calls and ignores source name/workspace", () => { | |
| 46 | + const source = makeSource(); | |
| 47 | + const renamed = makeSource({ | |
| 48 | + name: "Renamed GitHub", | |
| 49 | + }); | |
| 50 | + const differentWorkspace = makeSource({ | |
| 51 | + workspaceId: WorkspaceIdSchema.make("ws_source_definitions_other"), | |
| 52 | + }); | |
| 53 | + | |
| 54 | + expect(stableSourceRecipeId(source)).toBe(stableSourceRecipeId(source)); | |
| 55 | + expect(stableSourceRecipeRevisionId(source)).toBe(stableSourceRecipeRevisionId(source)); | |
| 56 | + expect(stableSourceRecipeId(renamed)).toBe(stableSourceRecipeId(source)); | |
| 57 | + expect(stableSourceRecipeRevisionId(renamed)).toBe(stableSourceRecipeRevisionId(source)); | |
| 58 | + expect(stableSourceRecipeId(differentWorkspace)).toBe(stableSourceRecipeId(source)); | |
| 59 | + expect(stableSourceRecipeRevisionId(differentWorkspace)).toBe( | |
| 60 | + stableSourceRecipeRevisionId(source), | |
| 61 | + ); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it("changes recipe and revision ids when the source config changes", () => { | |
| 65 | + const source = makeSource(); | |
| 66 | + const changedEndpoint = makeSource({ | |
| 67 | + endpoint: "https://example.com", | |
| 68 | + specUrl: "https://example.com/openapi.json", | |
| 69 | + }); | |
| 70 | + | |
| 71 | + expect(stableSourceRecipeId(changedEndpoint)).not.toBe(stableSourceRecipeId(source)); | |
| 72 | + expect(stableSourceRecipeRevisionId(changedEndpoint)).not.toBe( | |
| 73 | + stableSourceRecipeRevisionId(source), | |
| 74 | + ); | |
| 75 | + }); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + describe("payload normalization and validation", () => { | |
| 79 | + it("defaults created sources to draft/enabled and trims fields", async () => { | |
| 80 | + const source = await Effect.runPromise(createSourceFromPayload({ | |
| 81 | + workspaceId: WorkspaceIdSchema.make("ws_create_defaults"), | |
| 82 | + sourceId: SourceIdSchema.make("src_create_defaults"), | |
| 83 | + payload: { | |
| 84 | + name: " GitHub ", | |
| 85 | + kind: "openapi", | |
| 86 | + endpoint: " https://api.github.com ", | |
| 87 | + specUrl: " https://api.github.com/openapi.json ", | |
| 88 | + }, | |
| 89 | + now: 1234, | |
| 90 | + })); | |
| 91 | + | |
| 92 | + expect(source.name).toBe("GitHub"); | |
| 93 | + expect(source.endpoint).toBe("https://api.github.com"); | |
| 94 | + expect(source.specUrl).toBe("https://api.github.com/openapi.json"); | |
| 95 | + expect(source.status).toBe("draft"); | |
| 96 | + expect(source.enabled).toBe(true); | |
| 97 | + expect(source.auth).toEqual({ kind: "none" }); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + it("preserves existing values on partial update and keeps auth when undefined", async () => { | |
| 101 | + const source = makeSource({ | |
| 102 | + kind: "graphql", | |
| 103 | + endpoint: "https://example.com/graphql", | |
| 104 | + specUrl: null, | |
| 105 | + auth: { | |
| 106 | + kind: "bearer", | |
| 107 | + headerName: "Authorization", | |
| 108 | + prefix: "Bearer ", | |
| 109 | + token: { | |
| 110 | + providerId: "postgres", | |
| 111 | + handle: "sec_token", | |
| 112 | + }, | |
| 113 | + }, | |
| 114 | + defaultHeaders: { accept: "application/json" }, | |
| 115 | + }); | |
| 116 | + | |
| 117 | + const updated = await Effect.runPromise(updateSourceFromPayload({ | |
| 118 | + source, | |
| 119 | + payload: { | |
| 120 | + status: "error", | |
| 121 | + lastError: "bad gateway", | |
| 122 | + }, | |
| 123 | + now: 2000, | |
| 124 | + })); | |
| 125 | + | |
| 126 | + expect(updated.name).toBe(source.name); | |
| 127 | + expect(updated.endpoint).toBe(source.endpoint); | |
| 128 | + expect(updated.queryParams).toEqual(source.queryParams); | |
| 129 | + expect(updated.defaultHeaders).toEqual(source.defaultHeaders); | |
| 130 | + expect(updated.auth).toEqual(source.auth); | |
| 131 | + expect(updated.status).toBe("error"); | |
| 132 | + expect(updated.lastError).toBe("bad gateway"); | |
| 133 | + expect(updated.updatedAt).toBe(2000); | |
| 134 | + }); | |
| 135 | + | |
| 136 | + it("normalizes oauth2 auth defaults and allows null refresh tokens", async () => { | |
| 137 | + const source = await Effect.runPromise(createSourceFromPayload({ | |
| 138 | + workspaceId: WorkspaceIdSchema.make("ws_create_oauth_defaults"), | |
| 139 | + sourceId: SourceIdSchema.make("src_create_oauth_defaults"), | |
| 140 | + payload: { | |
| 141 | + name: "GraphQL Demo", | |
| 142 | + kind: "graphql", | |
| 143 | + endpoint: "https://example.com/graphql", | |
| 144 | + auth: { | |
| 145 | + kind: "oauth2", | |
| 146 | + headerName: " ", | |
| 147 | + prefix: undefined, | |
| 148 | + accessToken: { | |
| 149 | + providerId: " postgres ", | |
| 150 | + handle: " sec_access ", | |
| 151 | + }, | |
| 152 | + refreshToken: null, | |
| 153 | + } as never, | |
| 154 | + }, | |
| 155 | + now: 1234, | |
| 156 | + })); | |
| 157 | + | |
| 158 | + expect(source.auth).toEqual({ | |
| 159 | + kind: "oauth2", | |
| 160 | + headerName: "Authorization", | |
| 161 | + prefix: "Bearer ", | |
| 162 | + accessToken: { | |
| 163 | + providerId: "postgres", | |
| 164 | + handle: "sec_access", | |
| 165 | + }, | |
| 166 | + refreshToken: null, | |
| 167 | + }); | |
| 168 | + }); | |
| 169 | + | |
| 170 | + it("rejects invalid bearer and oauth2 secret refs", async () => { | |
| 171 | + await expect(Effect.runPromise(createSourceFromPayload({ | |
| 172 | + workspaceId: WorkspaceIdSchema.make("ws_invalid_bearer"), | |
| 173 | + sourceId: SourceIdSchema.make("src_invalid_bearer"), | |
| 174 | + payload: { | |
| 175 | + name: "Bad Bearer", | |
| 176 | + kind: "openapi", | |
| 177 | + endpoint: "https://example.com", | |
| 178 | + specUrl: "https://example.com/openapi.json", | |
| 179 | + auth: { | |
| 180 | + kind: "bearer", | |
| 181 | + headerName: "Authorization", | |
| 182 | + prefix: "Bearer ", | |
| 183 | + token: { | |
| 184 | + providerId: " ", | |
| 185 | + handle: "sec_token", | |
| 186 | + }, | |
| 187 | + }, | |
| 188 | + }, | |
| 189 | + now: 1234, | |
| 190 | + }))).rejects.toThrow("Bearer auth requires a token secret ref"); | |
| 191 | + | |
| 192 | + await expect(Effect.runPromise(createSourceFromPayload({ | |
| 193 | + workspaceId: WorkspaceIdSchema.make("ws_invalid_refresh"), | |
| 194 | + sourceId: SourceIdSchema.make("src_invalid_refresh"), | |
| 195 | + payload: { | |
| 196 | + name: "Bad OAuth", | |
| 197 | + kind: "graphql", | |
| 198 | + endpoint: "https://example.com/graphql", | |
| 199 | + auth: { | |
| 200 | + kind: "oauth2", | |
| 201 | + headerName: "Authorization", | |
| 202 | + prefix: "Bearer ", | |
| 203 | + accessToken: { | |
| 204 | + providerId: "postgres", | |
| 205 | + handle: "sec_access", | |
| 206 | + }, | |
| 207 | + refreshToken: { | |
| 208 | + providerId: "postgres", | |
| 209 | + handle: " ", | |
| 210 | + }, | |
| 211 | + } as never, | |
| 212 | + }, | |
| 213 | + now: 1234, | |
| 214 | + }))).rejects.toThrow("OAuth2 refresh token ref must include providerId and handle"); | |
| 215 | + }); | |
| 216 | + | |
| 217 | + it("rejects invalid source kind combinations", async () => { | |
| 218 | + await expect(Effect.runPromise(createSourceFromPayload({ | |
| 219 | + workspaceId: WorkspaceIdSchema.make("ws_invalid_mcp"), | |
| 220 | + sourceId: SourceIdSchema.make("src_invalid_mcp"), | |
| 221 | + payload: { | |
| 222 | + name: "MCP", | |
| 223 | + kind: "mcp", | |
| 224 | + endpoint: "https://example.com/mcp", | |
| 225 | + specUrl: "https://example.com/openapi.json", | |
| 226 | + } as never, | |
| 227 | + now: 1234, | |
| 228 | + }))).rejects.toThrow("MCP sources cannot define specUrl"); | |
| 229 | + | |
| 230 | + await expect(Effect.runPromise(createSourceFromPayload({ | |
| 231 | + workspaceId: WorkspaceIdSchema.make("ws_invalid_openapi_spec"), | |
| 232 | + sourceId: SourceIdSchema.make("src_invalid_openapi_spec"), | |
| 233 | + payload: { | |
| 234 | + name: "OpenAPI", | |
| 235 | + kind: "openapi", | |
| 236 | + endpoint: "https://example.com", | |
| 237 | + specUrl: " ", | |
| 238 | + } as never, | |
| 239 | + now: 1234, | |
| 240 | + }))).rejects.toThrow("OpenAPI sources require specUrl"); | |
| 241 | + | |
| 242 | + await expect(Effect.runPromise(createSourceFromPayload({ | |
| 243 | + workspaceId: WorkspaceIdSchema.make("ws_invalid_openapi_transport"), | |
| 244 | + sourceId: SourceIdSchema.make("src_invalid_openapi_transport"), | |
| 245 | + payload: { | |
| 246 | + name: "OpenAPI", | |
| 247 | + kind: "openapi", | |
| 248 | + endpoint: "https://example.com", | |
| 249 | + specUrl: "https://example.com/openapi.json", | |
| 250 | + transport: "sse", | |
| 251 | + } as never, | |
| 252 | + now: 1234, | |
| 253 | + }))).rejects.toThrow("OpenAPI sources cannot define MCP transport settings"); | |
| 254 | + | |
| 255 | + await expect(Effect.runPromise(createSourceFromPayload({ | |
| 256 | + workspaceId: WorkspaceIdSchema.make("ws_invalid_graphql"), | |
| 257 | + sourceId: SourceIdSchema.make("src_invalid_graphql"), | |
| 258 | + payload: { | |
| 259 | + name: "GraphQL", | |
| 260 | + kind: "graphql", | |
| 261 | + endpoint: "https://example.com/graphql", | |
| 262 | + specUrl: "https://example.com/openapi.json", | |
| 263 | + } as never, | |
| 264 | + now: 1234, | |
| 265 | + }))).rejects.toThrow("GraphQL sources cannot define specUrl"); | |
| 266 | + | |
| 267 | + await expect(Effect.runPromise(createSourceFromPayload({ | |
| 268 | + workspaceId: WorkspaceIdSchema.make("ws_invalid_internal"), | |
| 269 | + sourceId: SourceIdSchema.make("src_invalid_internal"), | |
| 270 | + payload: { | |
| 271 | + name: "Internal", | |
| 272 | + kind: "internal", | |
| 273 | + endpoint: "internal://executor", | |
| 274 | + defaultHeaders: { | |
| 275 | + accept: "application/json", | |
| 276 | + }, | |
| 277 | + } as never, | |
| 278 | + now: 1234, | |
| 279 | + }))).rejects.toThrow("internal sources cannot define OpenAPI settings"); | |
| 280 | + }); | |
| 281 | + | |
| 282 | + it("normalizes new auth during updates", async () => { | |
| 283 | + const updated = await Effect.runPromise(updateSourceFromPayload({ | |
| 284 | + source: makeSource({ | |
| 285 | + auth: { kind: "none" }, | |
| 286 | + }), | |
| 287 | + payload: { | |
| 288 | + auth: { | |
| 289 | + kind: "bearer", | |
| 290 | + headerName: " ", | |
| 291 | + prefix: "Token ", | |
| 292 | + token: { | |
| 293 | + providerId: " postgres ", | |
| 294 | + handle: " sec_token ", | |
| 295 | + }, | |
| 296 | + } as never, | |
| 297 | + }, | |
| 298 | + now: 2000, | |
| 299 | + })); | |
| 300 | + | |
| 301 | + expect(updated.auth).toEqual({ | |
| 302 | + kind: "bearer", | |
| 303 | + headerName: "Authorization", | |
| 304 | + prefix: "Token ", | |
| 305 | + token: { | |
| 306 | + providerId: "postgres", | |
| 307 | + handle: "sec_token", | |
| 308 | + }, | |
| 309 | + }); | |
| 310 | + }); | |
| 311 | + }); | |
| 312 | + | |
| 313 | + describe("storage roundtrip", () => { | |
| 314 | + it("roundtrips bearer auth and serialized maps", async () => { | |
| 315 | + const source = makeSource({ | |
| 316 | + queryParams: { page: "1" }, | |
| 317 | + headers: { "x-api-key": "secret" }, | |
| 318 | + defaultHeaders: { accept: "application/json" }, | |
| 319 | + auth: { | |
| 320 | + kind: "bearer", | |
| 321 | + headerName: "Authorization", | |
| 322 | + prefix: "Bearer ", | |
| 323 | + token: { | |
| 324 | + providerId: "postgres", | |
| 325 | + handle: "sec_bearer", | |
| 326 | + }, | |
| 327 | + }, | |
| 328 | + }); | |
| 329 | + const recipeId = stableSourceRecipeId(source); | |
| 330 | + const recipeRevisionId = stableSourceRecipeRevisionId(source); | |
| 331 | + const existingCredentialId = CredentialIdSchema.make("cred_existing"); | |
| 332 | + | |
| 333 | + const { sourceRecord, credential } = splitSourceForStorage({ | |
| 334 | + source, | |
| 335 | + recipeId, | |
| 336 | + recipeRevisionId, | |
| 337 | + existingCredentialId, | |
| 338 | + }); | |
| 339 | + | |
| 340 | + expect(credential?.id).toBe(existingCredentialId); | |
| 341 | + expect(sourceRecord.queryParamsJson).toBe(JSON.stringify(source.queryParams)); | |
| 342 | + expect(sourceRecord.headersJson).toBe(JSON.stringify(source.headers)); | |
| 343 | + expect(sourceRecord.defaultHeadersJson).toBe(JSON.stringify(source.defaultHeaders)); | |
| 344 | + | |
| 345 | + const projected = await Effect.runPromise(projectSourceFromStorage({ | |
| 346 | + sourceRecord, | |
| 347 | + credential: credential ?? null, | |
| 348 | + })); | |
| 349 | + | |
| 350 | + expect(projected).toEqual(source); | |
| 351 | + }); | |
| 352 | + | |
| 353 | + it("roundtrips oauth2 auth with and without refresh tokens", async () => { | |
| 354 | + const withRefresh = makeSource({ | |
| 355 | + kind: "graphql", | |
| 356 | + endpoint: "https://example.com/graphql", | |
| 357 | + specUrl: null, | |
| 358 | + defaultHeaders: { accept: "application/json" }, | |
| 359 | + auth: { | |
| 360 | + kind: "oauth2", | |
| 361 | + headerName: "Authorization", | |
| 362 | + prefix: "Bearer ", | |
| 363 | + accessToken: { | |
| 364 | + providerId: "postgres", | |
| 365 | + handle: "sec_access", | |
| 366 | + }, | |
| 367 | + refreshToken: { | |
| 368 | + providerId: "postgres", | |
| 369 | + handle: "sec_refresh", | |
| 370 | + }, | |
| 371 | + }, | |
| 372 | + }); | |
| 373 | + const withoutRefresh = makeSource({ | |
| 374 | + id: SourceIdSchema.make("src_source_definitions_no_refresh"), | |
| 375 | + kind: "graphql", | |
| 376 | + endpoint: "https://example.com/graphql", | |
| 377 | + specUrl: null, | |
| 378 | + defaultHeaders: { accept: "application/json" }, | |
| 379 | + auth: { | |
| 380 | + kind: "oauth2", | |
| 381 | + headerName: "Authorization", | |
| 382 | + prefix: "Bearer ", | |
| 383 | + accessToken: { | |
| 384 | + providerId: "postgres", | |
| 385 | + handle: "sec_access", | |
| 386 | + }, | |
| 387 | + refreshToken: null, | |
| 388 | + }, | |
| 389 | + }); | |
| 390 | + | |
| 391 | + for (const source of [withRefresh, withoutRefresh]) { | |
| 392 | + const { sourceRecord, credential } = splitSourceForStorage({ | |
| 393 | + source, | |
| 394 | + recipeId: stableSourceRecipeId(source), | |
| 395 | + recipeRevisionId: stableSourceRecipeRevisionId(source), | |
| 396 | + }); | |
| 397 | + const projected = await Effect.runPromise(projectSourceFromStorage({ | |
| 398 | + sourceRecord, | |
| 399 | + credential: credential ?? null, | |
| 400 | + })); | |
| 401 | + | |
| 402 | + expect(projected).toEqual(source); | |
| 403 | + } | |
| 404 | + }); | |
| 405 | + | |
| 406 | + it("stores no credential for auth.kind none and projects back correctly", async () => { | |
| 407 | + const source = makeSource({ | |
| 408 | + auth: { kind: "none" }, | |
| 409 | + }); | |
| 410 | + const { sourceRecord, credential } = splitSourceForStorage({ | |
| 411 | + source, | |
| 412 | + recipeId: stableSourceRecipeId(source), | |
| 413 | + recipeRevisionId: stableSourceRecipeRevisionId(source), | |
| 414 | + }); | |
| 415 | + | |
| 416 | + expect(credential).toBeNull(); | |
| 417 | + | |
| 418 | + const projected = await Effect.runPromise(projectSourceFromStorage({ | |
| 419 | + sourceRecord, | |
| 420 | + credential: null, | |
| 421 | + })); | |
| 422 | + | |
| 423 | + expect(projected.auth).toEqual({ kind: "none" }); | |
| 424 | + expect(projected).toEqual(source); | |
| 425 | + }); | |
| 426 | + }); | |
| 427 | + | |
| 428 | + describe("namespaceFromSourceName", () => { | |
| 429 | + it("normalizes names into namespace-safe dotted segments", () => { | |
| 430 | + expect(namespaceFromSourceName("My API v2!")).toBe("my.api.v2"); | |
| 431 | + expect(namespaceFromSourceName(" ")).toBe("source"); | |
| 432 | + expect(namespaceFromSourceName("!!!")).toBe("source"); | |
| 433 | + }); | |
| 434 | + }); | |
| 435 | +}); | |
| @@ -1,18 +1,29 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | + | |
| 1 | 3 | import type { |
| 2 | 4 | CreateSourcePayload, |
| 3 | 5 | UpdateSourcePayload, |
| 4 | 6 | } from "#api"; |
| 5 | 7 | import type { |
| 8 | + AccountId, | |
| 6 | 9 | Credential, |
| 7 | 10 | Source, |
| 8 | 11 | SourceAuth, |
| 9 | − SourceCredentialBinding, | |
| 10 | − SourceId, | |
| 12 | + SourceRecipeId, | |
| 13 | + SourceRecipeImporterKind, | |
| 14 | + SourceRecipeKind, | |
| 15 | + SourceRecipeRevisionId, | |
| 11 | 16 | StoredSourceRecord, |
| 17 | + StoredSourceRecipeRecord, | |
| 18 | + StoredSourceRecipeRevisionRecord, | |
| 12 | 19 | StringMap, |
| 13 | 20 | WorkspaceId, |
| 14 | 21 | } from "#schema"; |
| 15 | −import { CredentialIdSchema } from "#schema"; | |
| 22 | +import { | |
| 23 | + CredentialIdSchema, | |
| 24 | + SourceRecipeIdSchema, | |
| 25 | + SourceRecipeRevisionIdSchema, | |
| 26 | +} from "#schema"; | |
| 16 | 27 | import * as Effect from "effect/Effect"; |
| 17 | 28 | import * as Schema from "effect/Schema"; |
| 18 | 29 | |
| @@ -58,6 +69,134 @@ const parseStringMapJson = ( · 39 unmodified lines | ||
| 58 | 69 | ), |
| 59 | 70 | ); |
| 60 | 71 | |
| 72 | +type SourceRecipeSourceConfig = | |
| 73 | + | { | |
| 74 | + kind: "mcp"; | |
| 75 | + endpoint: string; | |
| 76 | + transport: Source["transport"]; | |
| 77 | + queryParams: Source["queryParams"]; | |
| 78 | + headers: Source["headers"]; | |
| 79 | + } | |
| 80 | + | { | |
| 81 | + kind: "openapi"; | |
| 82 | + endpoint: string; | |
| 83 | + specUrl: string; | |
| 84 | + defaultHeaders: Source["defaultHeaders"]; | |
| 85 | + } | |
| 86 | + | { | |
| 87 | + kind: "graphql"; | |
| 88 | + endpoint: string; | |
| 89 | + defaultHeaders: Source["defaultHeaders"]; | |
| 90 | + } | |
| 91 | + | { | |
| 92 | + kind: "internal"; | |
| 93 | + endpoint: string; | |
| 94 | + }; | |
| 95 | + | |
| 96 | +const sourceConfigFromSource = (source: Source): SourceRecipeSourceConfig => { | |
| 97 | + if (source.kind === "mcp") { | |
| 98 | + return { | |
| 99 | + kind: "mcp", | |
| 100 | + endpoint: source.endpoint, | |
| 101 | + transport: source.transport, | |
| 102 | + queryParams: source.queryParams, | |
| 103 | + headers: source.headers, | |
| 104 | + }; | |
| 105 | + } | |
| 106 | + | |
| 107 | + if (source.kind === "openapi") { | |
| 108 | + return { | |
| 109 | + kind: "openapi", | |
| 110 | + endpoint: source.endpoint, | |
| 111 | + specUrl: source.specUrl ?? source.endpoint, | |
| 112 | + defaultHeaders: source.defaultHeaders, | |
| 113 | + }; | |
| 114 | + } | |
| 115 | + | |
| 116 | + if (source.kind === "graphql") { | |
| 117 | + return { | |
| 118 | + kind: "graphql", | |
| 119 | + endpoint: source.endpoint, | |
| 120 | + defaultHeaders: source.defaultHeaders, | |
| 121 | + }; | |
| 122 | + } | |
| 123 | + | |
| 124 | + return { | |
| 125 | + kind: "internal", | |
| 126 | + endpoint: source.endpoint, | |
| 127 | + }; | |
| 128 | +}; | |
| 129 | + | |
| 130 | +const sourceRecipeKindFromSource = (source: Source): SourceRecipeKind => { | |
| 131 | + if (source.kind === "mcp") { | |
| 132 | + return "mcp_recipe"; | |
| 133 | + } | |
| 134 | + | |
| 135 | + if (source.kind === "graphql") { | |
| 136 | + return "graphql_recipe"; | |
| 137 | + } | |
| 138 | + | |
| 139 | + if (source.kind === "openapi") { | |
| 140 | + return "http_recipe"; | |
| 141 | + } | |
| 142 | + | |
| 143 | + return "internal_recipe"; | |
| 144 | +}; | |
| 145 | + | |
| 146 | +const sourceRecipeImporterKindFromSource = (source: Source): SourceRecipeImporterKind => { | |
| 147 | + if (source.kind === "mcp") { | |
| 148 | + return "mcp_manifest"; | |
| 149 | + } | |
| 150 | + | |
| 151 | + if (source.kind === "graphql") { | |
| 152 | + return "graphql_introspection"; | |
| 153 | + } | |
| 154 | + | |
| 155 | + if (source.kind === "openapi") { | |
| 156 | + return "openapi"; | |
| 157 | + } | |
| 158 | + | |
| 159 | + return "internal_manifest"; | |
| 160 | +}; | |
| 161 | + | |
| 162 | +const sourceRecipeProviderKeyFromSource = (source: Source): string => { | |
| 163 | + if (source.kind === "mcp") { | |
| 164 | + return "generic_mcp"; | |
| 165 | + } | |
| 166 | + | |
| 167 | + if (source.kind === "graphql") { | |
| 168 | + return "generic_graphql"; | |
| 169 | + } | |
| 170 | + | |
| 171 | + if (source.kind === "openapi") { | |
| 172 | + return "generic_http"; | |
| 173 | + } | |
| 174 | + | |
| 175 | + return "generic_internal"; | |
| 176 | +}; | |
| 177 | + | |
| 178 | +const stableHash = (value: string): string => | |
| 179 | + createHash("sha256").update(value).digest("hex").slice(0, 24); | |
| 180 | + | |
| 181 | +const sourceRecipeSignature = (source: Source): string => | |
| 182 | + JSON.stringify({ | |
| 183 | + recipeKind: sourceRecipeKindFromSource(source), | |
| 184 | + importerKind: sourceRecipeImporterKindFromSource(source), | |
| 185 | + providerKey: sourceRecipeProviderKeyFromSource(source), | |
| 186 | + sourceConfig: sourceConfigFromSource(source), | |
| 187 | + }); | |
| 188 | + | |
| 189 | +export const sourceConfigSignature = (source: Source): string => | |
| 190 | + JSON.stringify(sourceConfigFromSource(source)); | |
| 191 | + | |
| 192 | +export const stableSourceRecipeId = (source: Source): SourceRecipeId => | |
| 193 | + SourceRecipeIdSchema.make(`src_recipe_${stableHash(sourceRecipeSignature(source))}`); | |
| 194 | + | |
| 195 | +export const stableSourceRecipeRevisionId = ( | |
| 196 | + source: Source, | |
| 197 | +): SourceRecipeRevisionId => | |
| 198 | + SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${stableHash(sourceConfigSignature(source))}`); | |
| 199 | + | |
| 61 | 200 | const normalizeAuth = ( |
| 62 | 201 | auth: SourceAuth | undefined, |
| 63 | 202 | ): Effect.Effect<SourceAuth, Error, never> => |
| @@ -175,7 +314,7 @@ const validateSourceByKind = (source: Source): Effect.Effect<Source, Error, neve · 111 unmodified lines | ||
| 175 | 314 | |
| 176 | 315 | export const createSourceFromPayload = (input: { |
| 177 | 316 | workspaceId: WorkspaceId; |
| 178 | − sourceId: SourceId; | |
| 317 | + sourceId: Source["id"]; | |
| 179 | 318 | payload: CreateSourcePayload; |
| 180 | 319 | now: number; |
| 181 | 320 | }): Effect.Effect<Source, Error, never> => |
| @@ -253,24 +392,65 @@ export const updateSourceFromPayload = (input: { · 71 unmodified lines | ||
| 253 | 392 | }); |
| 254 | 393 | }); |
| 255 | 394 | |
| 395 | +export const createSourceRecipeRecord = (input: { | |
| 396 | + source: Source; | |
| 397 | + recipeId?: SourceRecipeId | null; | |
| 398 | + latestRevisionId: SourceRecipeRevisionId; | |
| 399 | +}): StoredSourceRecipeRecord => ({ | |
| 400 | + id: input.recipeId ?? stableSourceRecipeId(input.source), | |
| 401 | + kind: sourceRecipeKindFromSource(input.source), | |
| 402 | + importerKind: sourceRecipeImporterKindFromSource(input.source), | |
| 403 | + providerKey: sourceRecipeProviderKeyFromSource(input.source), | |
| 404 | + name: input.source.name, | |
| 405 | + summary: null, | |
| 406 | + visibility: "workspace", | |
| 407 | + latestRevisionId: input.latestRevisionId, | |
| 408 | + createdAt: input.source.createdAt, | |
| 409 | + updatedAt: input.source.updatedAt, | |
| 410 | +}); | |
| 411 | + | |
| 412 | +export const createSourceRecipeRevisionRecord = (input: { | |
| 413 | + source: Source; | |
| 414 | + recipeId: SourceRecipeId; | |
| 415 | + recipeRevisionId?: SourceRecipeRevisionId | null; | |
| 416 | + revisionNumber: number; | |
| 417 | + manifestJson?: string | null; | |
| 418 | + manifestHash?: string | null; | |
| 419 | +}): StoredSourceRecipeRevisionRecord => ({ | |
| 420 | + id: | |
| 421 | + input.recipeRevisionId | |
| 422 | + ?? stableSourceRecipeRevisionId(input.source), | |
| 423 | + recipeId: input.recipeId, | |
| 424 | + revisionNumber: input.revisionNumber, | |
| 425 | + sourceConfigJson: sourceConfigSignature(input.source), | |
| 426 | + manifestJson: input.manifestJson ?? null, | |
| 427 | + manifestHash: input.manifestHash ?? null, | |
| 428 | + createdAt: input.source.createdAt, | |
| 429 | + updatedAt: input.source.updatedAt, | |
| 430 | +}); | |
| 431 | + | |
| 256 | 432 | export const splitSourceForStorage = (input: { |
| 257 | 433 | source: Source; |
| 434 | + recipeId: SourceRecipeId; | |
| 435 | + recipeRevisionId: SourceRecipeRevisionId; | |
| 436 | + actorAccountId?: AccountId | null; | |
| 258 | 437 | existingCredentialId?: Credential["id"] | null; |
| 259 | − existingBindingId?: SourceCredentialBinding["id"] | null; | |
| 260 | 438 | }): { |
| 261 | 439 | sourceRecord: StoredSourceRecord; |
| 262 | 440 | credential: Credential | null; |
| 263 | − credentialBinding: SourceCredentialBinding | null; | |
| 264 | 441 | } => { |
| 265 | 442 | const sourceRecord: StoredSourceRecord = { |
| 266 | 443 | id: input.source.id, |
| 267 | 444 | workspaceId: input.source.workspaceId, |
| 445 | + recipeId: input.recipeId, | |
| 446 | + recipeRevisionId: input.recipeRevisionId, | |
| 268 | 447 | name: input.source.name, |
| 269 | 448 | kind: input.source.kind, |
| 270 | 449 | endpoint: input.source.endpoint, |
| 271 | 450 | status: input.source.status, |
| 272 | 451 | enabled: input.source.enabled, |
| 273 | 452 | namespace: input.source.namespace, |
| 453 | + bindingConfigJson: null, | |
| 274 | 454 | transport: input.source.transport, |
| 275 | 455 | queryParamsJson: serializeStringMap(input.source.queryParams), |
| 276 | 456 | headersJson: serializeStringMap(input.source.headers), |
| @@ -287,17 +467,17 @@ export const splitSourceForStorage = (input: { · 10 unmodified lines | ||
| 287 | 467 | return { |
| 288 | 468 | sourceRecord, |
| 289 | 469 | credential: null, |
| 290 | − credentialBinding: null, | |
| 291 | 470 | }; |
| 292 | 471 | } |
| 293 | 472 | |
| 294 | 473 | const credentialId = input.existingCredentialId |
| 295 | 474 | ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`); |
| 296 | − const bindingId = input.existingBindingId ?? `src_cred_bind_${crypto.randomUUID()}`; | |
| 297 | 475 | |
| 298 | 476 | const credential: Credential = { |
| 299 | 477 | id: credentialId, |
| 300 | 478 | workspaceId: input.source.workspaceId, |
| 479 | + sourceId: input.source.id, | |
| 480 | + actorAccountId: input.actorAccountId ?? null, | |
| 301 | 481 | authKind: input.source.auth.kind, |
| 302 | 482 | authHeaderName: input.source.auth.headerName, |
| 303 | 483 | authPrefix: input.source.auth.prefix, |
| @@ -324,20 +504,11 @@ export const splitSourceForStorage = (input: { · 20 unmodified lines | ||
| 324 | 504 | return { |
| 325 | 505 | sourceRecord, |
| 326 | 506 | credential, |
| 327 | − credentialBinding: { | |
| 328 | − id: bindingId, | |
| 329 | − workspaceId: input.source.workspaceId, | |
| 330 | − sourceId: input.source.id, | |
| 331 | − credentialId, | |
| 332 | − createdAt: input.source.createdAt, | |
| 333 | − updatedAt: input.source.updatedAt, | |
| 334 | − }, | |
| 335 | 507 | }; |
| 336 | 508 | }; |
| 337 | 509 | |
| 338 | 510 | export const projectSourceFromStorage = (input: { |
| 339 | 511 | sourceRecord: StoredSourceRecord; |
| 340 | − credentialBinding: SourceCredentialBinding | null; | |
| 341 | 512 | credential: Credential | null; |
| 342 | 513 | }): Effect.Effect<Source, Error, never> => |
| 343 | 514 | Effect.gen(function* () { |
| @@ -355,38 +526,32 @@ export const projectSourceFromStorage = (input: { · 11 unmodified lines | ||
| 355 | 526 | ); |
| 356 | 527 | |
| 357 | 528 | let auth: SourceAuth = { kind: "none" }; |
| 358 | − if (input.credentialBinding !== null) { | |
| 359 | − const credential = input.credential; | |
| 360 | − if (credential === null) { | |
| 361 | − return yield* Effect.fail( | |
| 362 | − new Error(`Missing credential ${input.credentialBinding.credentialId} for source ${input.sourceRecord.id}`), | |
| 363 | − ); | |
| 364 | − } | |
| 365 | − | |
| 366 | − if (credential.authKind === "bearer") { | |
| 529 | + if (input.credential !== null) { | |
| 530 | + if (input.credential.authKind === "bearer") { | |
| 367 | 531 | auth = { |
| 368 | 532 | kind: "bearer", |
| 369 | − headerName: credential.authHeaderName, | |
| 370 | − prefix: credential.authPrefix, | |
| 533 | + headerName: input.credential.authHeaderName, | |
| 534 | + prefix: input.credential.authPrefix, | |
| 371 | 535 | token: { |
| 372 | − providerId: credential.tokenProviderId, | |
| 373 | − handle: credential.tokenHandle, | |
| 536 | + providerId: input.credential.tokenProviderId, | |
| 537 | + handle: input.credential.tokenHandle, | |
| 374 | 538 | }, |
| 375 | 539 | }; |
| 376 | 540 | } else { |
| 377 | 541 | auth = { |
| 378 | 542 | kind: "oauth2", |
| 379 | − headerName: credential.authHeaderName, | |
| 380 | − prefix: credential.authPrefix, | |
| 543 | + headerName: input.credential.authHeaderName, | |
| 544 | + prefix: input.credential.authPrefix, | |
| 381 | 545 | accessToken: { |
| 382 | − providerId: credential.tokenProviderId, | |
| 383 | − handle: credential.tokenHandle, | |
| 546 | + providerId: input.credential.tokenProviderId, | |
| 547 | + handle: input.credential.tokenHandle, | |
| 384 | 548 | }, |
| 385 | 549 | refreshToken: |
| 386 | − credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null | |
| 550 | + input.credential.refreshTokenProviderId !== null | |
| 551 | + && input.credential.refreshTokenHandle !== null | |
| 387 | 552 | ? { |
| 388 | − providerId: credential.refreshTokenProviderId, | |
| 389 | − handle: credential.refreshTokenHandle, | |
| 553 | + providerId: input.credential.refreshTokenProviderId, | |
| 554 | + handle: input.credential.refreshTokenHandle, | |
| 390 | 555 | } |
| 391 | 556 | : null, |
| 392 | 557 | }; |
| @@ -421,25 +586,20 @@ export const projectSourceFromStorage = (input: { · 28 unmodified lines | ||
| 421 | 586 | |
| 422 | 587 | export const projectSourcesFromStorage = (input: { |
| 423 | 588 | sourceRecords: ReadonlyArray<StoredSourceRecord>; |
| 424 | − credentialBindings: ReadonlyArray<SourceCredentialBinding>; | |
| 425 | 589 | credentials: ReadonlyArray<Credential>; |
| 426 | 590 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { |
| 427 | − const bindingsBySourceId = new Map( | |
| 428 | − input.credentialBindings.map((binding) => [binding.sourceId, binding]), | |
| 429 | − ); | |
| 430 | − const credentialsById = new Map( | |
| 431 | − input.credentials.map((credential) => [credential.id, credential]), | |
| 432 | − ); | |
| 591 | + const credentialsBySourceId = new Map<string, Credential>(); | |
| 592 | + | |
| 593 | + for (const credential of input.credentials) { | |
| 594 | + const existing = credentialsBySourceId.get(credential.sourceId); | |
| 595 | + if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) { | |
| 596 | + credentialsBySourceId.set(credential.sourceId, credential); | |
| 597 | + } | |
| 598 | + } | |
| 433 | 599 | |
| 434 | − return Effect.forEach(input.sourceRecords, (sourceRecord) => { | |
| 435 | − const credentialBinding = bindingsBySourceId.get(sourceRecord.id) ?? null; | |
| 436 | − return projectSourceFromStorage({ | |
| 600 | + return Effect.forEach(input.sourceRecords, (sourceRecord) => | |
| 601 | + projectSourceFromStorage({ | |
| 437 | 602 | sourceRecord, |
| 438 | − credentialBinding, | |
| 439 | − credential: | |
| 440 | − credentialBinding === null | |
| 441 | − ? null | |
| 442 | − : credentialsById.get(credentialBinding.credentialId) ?? null, | |
| 443 | − }); | |
| 444 | − }); | |
| 603 | + credential: credentialsBySourceId.get(sourceRecord.id) ?? null, | |
| 604 | + })); | |
| 445 | 605 | }; |
| @@ -11,11 +11,20 @@ import { · 10 unmodified lines | ||
| 11 | 11 | } from "#schema"; |
| 12 | 12 | import * as Effect from "effect/Effect"; |
| 13 | 13 | import * as Option from "effect/Option"; |
| 14 | +import * as Scope from "effect/Scope"; | |
| 14 | 15 | |
| 15 | −import { createSqlControlPlanePersistence } from "../persistence"; | |
| 16 | +import { | |
| 17 | + createSqlControlPlanePersistence, | |
| 18 | + type SqlControlPlanePersistence, | |
| 19 | +} from "../persistence"; | |
| 20 | +import { | |
| 21 | + stableSourceRecipeId, | |
| 22 | + stableSourceRecipeRevisionId, | |
| 23 | +} from "./source-definitions"; | |
| 16 | 24 | import { persistSource, removeSourceById } from "./source-store"; |
| 17 | 25 | |
| 18 | −const makePersistence = Effect.acquireRelease( | |
| 26 | +const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope.Scope> = | |
| 27 | + Effect.acquireRelease( | |
| 19 | 28 | createSqlControlPlanePersistence({ |
| 20 | 29 | localDataDir: ":memory:", |
| 21 | 30 | }), |
| @@ -24,27 +33,30 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines | ||
| 24 | 33 | try: () => persistence.close(), |
| 25 | 34 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), |
| 26 | 35 | }).pipe(Effect.orDie), |
| 27 | −); | |
| 36 | + ); | |
| 28 | 37 | |
| 29 | 38 | const makeOpenApiSource = (input: { |
| 30 | 39 | workspaceId: Source["workspaceId"]; |
| 31 | 40 | sourceId: Source["id"]; |
| 32 | 41 | now: number; |
| 33 | 42 | updatedAt?: number; |
| 43 | + name?: string; | |
| 44 | + endpoint?: string; | |
| 45 | + specUrl?: string; | |
| 34 | 46 | auth: Source["auth"]; |
| 35 | 47 | }): Source => ({ |
| 36 | 48 | id: input.sourceId, |
| 37 | 49 | workspaceId: input.workspaceId, |
| 38 | − name: "GitHub", | |
| 50 | + name: input.name ?? "GitHub", | |
| 39 | 51 | kind: "openapi", |
| 40 | − endpoint: "https://api.github.com", | |
| 52 | + endpoint: input.endpoint ?? "https://api.github.com", | |
| 41 | 53 | status: "connected", |
| 42 | 54 | enabled: true, |
| 43 | 55 | namespace: "github", |
| 44 | 56 | transport: null, |
| 45 | 57 | queryParams: null, |
| 46 | 58 | headers: null, |
| 47 | − specUrl: "https://example.com/openapi.json", | |
| 59 | + specUrl: input.specUrl ?? "https://example.com/openapi.json", | |
| 48 | 60 | defaultHeaders: null, |
| 49 | 61 | auth: input.auth, |
| 50 | 62 | sourceHash: null, |
| @@ -122,21 +134,25 @@ describe("source-store", () => { · 71 unmodified lines | ||
| 122 | 134 | id: SourceAuthSessionIdSchema.make("src_auth_source_store"), |
| 123 | 135 | workspaceId, |
| 124 | 136 | sourceId, |
| 137 | + actorAccountId: accountId, | |
| 125 | 138 | executionId: null, |
| 126 | 139 | interactionId: null, |
| 127 | − strategy: "oauth2_authorization_code", | |
| 140 | + providerKind: "mcp_oauth", | |
| 128 | 141 | status: "pending", |
| 129 | − endpoint: "https://api.github.com", | |
| 130 | 142 | state: "state_source_store", |
| 131 | − redirectUri: "http://127.0.0.1/callback", | |
| 132 | − scope: null, | |
| 133 | − resourceMetadataUrl: null, | |
| 134 | − authorizationServerUrl: null, | |
| 135 | − resourceMetadataJson: null, | |
| 136 | − authorizationServerMetadataJson: null, | |
| 137 | − clientInformationJson: null, | |
| 138 | − codeVerifier: "verifier", | |
| 139 | − authorizationUrl: "https://example.com/auth", | |
| 143 | + sessionDataJson: JSON.stringify({ | |
| 144 | + kind: "mcp_oauth", | |
| 145 | + endpoint: "https://api.github.com", | |
| 146 | + redirectUri: "http://127.0.0.1/callback", | |
| 147 | + scope: null, | |
| 148 | + resourceMetadataUrl: null, | |
| 149 | + authorizationServerUrl: null, | |
| 150 | + resourceMetadataJson: null, | |
| 151 | + authorizationServerMetadataJson: null, | |
| 152 | + clientInformationJson: null, | |
| 153 | + codeVerifier: "verifier", | |
| 154 | + authorizationUrl: "https://example.com/auth", | |
| 155 | + }), | |
| 140 | 156 | errorText: null, |
| 141 | 157 | completedAt: null, |
| 142 | 158 | createdAt: now, |
| @@ -177,8 +193,321 @@ describe("source-store", () => { · 34 unmodified lines | ||
| 177 | 193 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); |
| 178 | 194 | expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 179 | 195 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 180 | − expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0); | |
| 181 | 196 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 182 | 197 | }), |
| 183 | 198 | ); |
| 199 | + | |
| 200 | + it.scoped("creates a fresh actor-scoped credential when a shared credential already exists", () => | |
| 201 | + Effect.gen(function* () { | |
| 202 | + const persistence = yield* makePersistence; | |
| 203 | + const now = Date.now(); | |
| 204 | + const accountId = AccountIdSchema.make("acc_actor_scoped"); | |
| 205 | + const organizationId = OrganizationIdSchema.make("org_actor_scoped"); | |
| 206 | + const workspaceId = WorkspaceIdSchema.make("ws_actor_scoped"); | |
| 207 | + const sourceId = SourceIdSchema.make("src_actor_scoped"); | |
| 208 | + const sharedTokenId = SecretMaterialIdSchema.make("sec_actor_scoped_shared"); | |
| 209 | + | |
| 210 | + yield* persistence.rows.organizations.insert({ | |
| 211 | + id: organizationId, | |
| 212 | + slug: "actor-scoped", | |
| 213 | + name: "Actor Scoped", | |
| 214 | + status: "active", | |
| 215 | + createdByAccountId: accountId, | |
| 216 | + createdAt: now, | |
| 217 | + updatedAt: now, | |
| 218 | + }); | |
| 219 | + yield* persistence.rows.workspaces.insert({ | |
| 220 | + id: workspaceId, | |
| 221 | + organizationId, | |
| 222 | + name: "Primary", | |
| 223 | + createdByAccountId: accountId, | |
| 224 | + createdAt: now, | |
| 225 | + updatedAt: now, | |
| 226 | + }); | |
| 227 | + yield* persistence.rows.secretMaterials.upsert({ | |
| 228 | + id: sharedTokenId, | |
| 229 | + name: null, | |
| 230 | + purpose: "auth_material", | |
| 231 | + value: "ghp_shared", | |
| 232 | + createdAt: now, | |
| 233 | + updatedAt: now, | |
| 234 | + }); | |
| 235 | + | |
| 236 | + const source = makeOpenApiSource({ | |
| 237 | + workspaceId, | |
| 238 | + sourceId, | |
| 239 | + now, | |
| 240 | + auth: { | |
| 241 | + kind: "bearer", | |
| 242 | + headerName: "Authorization", | |
| 243 | + prefix: "Bearer ", | |
| 244 | + token: { | |
| 245 | + providerId: "postgres", | |
| 246 | + handle: sharedTokenId, | |
| 247 | + }, | |
| 248 | + }, | |
| 249 | + }); | |
| 250 | + | |
| 251 | + yield* persistSource(persistence.rows, source); | |
| 252 | + yield* persistSource( | |
| 253 | + persistence.rows, | |
| 254 | + { | |
| 255 | + ...source, | |
| 256 | + updatedAt: now + 1, | |
| 257 | + }, | |
| 258 | + { | |
| 259 | + actorAccountId: accountId, | |
| 260 | + }, | |
| 261 | + ); | |
| 262 | + | |
| 263 | + const credentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({ | |
| 264 | + workspaceId, | |
| 265 | + sourceId, | |
| 266 | + }); | |
| 267 | + expect(credentials).toHaveLength(2); | |
| 268 | + expect(credentials.some((credential) => credential.actorAccountId === null)).toBe(true); | |
| 269 | + expect(credentials.some((credential) => credential.actorAccountId === accountId)).toBe(true); | |
| 270 | + expect(new Set(credentials.map((credential) => credential.id)).size).toBe(2); | |
| 271 | + }), | |
| 272 | + ); | |
| 273 | + | |
| 274 | + it.scoped("cleans up orphaned recipe data when the source config changes", () => | |
| 275 | + Effect.gen(function* () { | |
| 276 | + const persistence = yield* makePersistence; | |
| 277 | + const now = Date.now(); | |
| 278 | + const accountId = AccountIdSchema.make("acc_recipe_rewrite"); | |
| 279 | + const organizationId = OrganizationIdSchema.make("org_recipe_rewrite"); | |
| 280 | + const workspaceId = WorkspaceIdSchema.make("ws_recipe_rewrite"); | |
| 281 | + const sourceId = SourceIdSchema.make("src_recipe_rewrite"); | |
| 282 | + | |
| 283 | + yield* persistence.rows.organizations.insert({ | |
| 284 | + id: organizationId, | |
| 285 | + slug: "recipe-rewrite", | |
| 286 | + name: "Recipe Rewrite", | |
| 287 | + status: "active", | |
| 288 | + createdByAccountId: accountId, | |
| 289 | + createdAt: now, | |
| 290 | + updatedAt: now, | |
| 291 | + }); | |
| 292 | + yield* persistence.rows.workspaces.insert({ | |
| 293 | + id: workspaceId, | |
| 294 | + organizationId, | |
| 295 | + name: "Primary", | |
| 296 | + createdByAccountId: accountId, | |
| 297 | + createdAt: now, | |
| 298 | + updatedAt: now, | |
| 299 | + }); | |
| 300 | + | |
| 301 | + const initialSource = makeOpenApiSource({ | |
| 302 | + workspaceId, | |
| 303 | + sourceId, | |
| 304 | + now, | |
| 305 | + auth: { kind: "none" }, | |
| 306 | + }); | |
| 307 | + const initialRecipeId = stableSourceRecipeId(initialSource); | |
| 308 | + const initialRecipeRevisionId = stableSourceRecipeRevisionId(initialSource); | |
| 309 | + | |
| 310 | + yield* persistSource(persistence.rows, initialSource); | |
| 311 | + yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 312 | + recipeRevisionId: initialRecipeRevisionId, | |
| 313 | + documents: [{ | |
| 314 | + id: "src_recipe_doc_recipe_rewrite", | |
| 315 | + recipeRevisionId: initialRecipeRevisionId, | |
| 316 | + documentKind: "openapi", | |
| 317 | + documentKey: initialSource.specUrl ?? initialSource.endpoint, | |
| 318 | + contentText: "{}", | |
| 319 | + contentHash: "hash_recipe_rewrite", | |
| 320 | + fetchedAt: now, | |
| 321 | + createdAt: now, | |
| 322 | + updatedAt: now, | |
| 323 | + }], | |
| 324 | + }); | |
| 325 | + yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 326 | + recipeRevisionId: initialRecipeRevisionId, | |
| 327 | + operations: [{ | |
| 328 | + id: "src_recipe_op_recipe_rewrite", | |
| 329 | + recipeRevisionId: initialRecipeRevisionId, | |
| 330 | + operationKey: "getRepo", | |
| 331 | + transportKind: "http", | |
| 332 | + toolId: "getRepo", | |
| 333 | + title: "Get Repo", | |
| 334 | + description: "Read a repository", | |
| 335 | + operationKind: "read", | |
| 336 | + searchText: "github get repo", | |
| 337 | + inputSchemaJson: null, | |
| 338 | + outputSchemaJson: null, | |
| 339 | + providerKind: "openapi", | |
| 340 | + providerDataJson: null, | |
| 341 | + mcpToolName: null, | |
| 342 | + openApiMethod: "get", | |
| 343 | + openApiPathTemplate: "/repos/{owner}/{repo}", | |
| 344 | + openApiOperationHash: "hash_recipe_rewrite", | |
| 345 | + openApiRawToolId: "repos_getRepo", | |
| 346 | + openApiOperationId: "repos.getRepo", | |
| 347 | + openApiTagsJson: JSON.stringify(["repos"]), | |
| 348 | + openApiRequestBodyRequired: null, | |
| 349 | + graphqlOperationType: null, | |
| 350 | + graphqlOperationName: null, | |
| 351 | + createdAt: now, | |
| 352 | + updatedAt: now, | |
| 353 | + }], | |
| 354 | + }); | |
| 355 | + | |
| 356 | + const updatedSource = makeOpenApiSource({ | |
| 357 | + workspaceId, | |
| 358 | + sourceId, | |
| 359 | + now, | |
| 360 | + updatedAt: now + 1, | |
| 361 | + endpoint: "https://api.example.com", | |
| 362 | + specUrl: "https://api.example.com/openapi.json", | |
| 363 | + auth: { kind: "none" }, | |
| 364 | + }); | |
| 365 | + const nextRecipeId = stableSourceRecipeId(updatedSource); | |
| 366 | + const nextRecipeRevisionId = stableSourceRecipeRevisionId(updatedSource); | |
| 367 | + | |
| 368 | + yield* persistSource(persistence.rows, updatedSource); | |
| 369 | + | |
| 370 | + expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(initialRecipeId))).toBe(true); | |
| 371 | + expect( | |
| 372 | + Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(initialRecipeRevisionId)), | |
| 373 | + ).toBe(true); | |
| 374 | + expect( | |
| 375 | + yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(initialRecipeRevisionId), | |
| 376 | + ).toEqual([]); | |
| 377 | + expect( | |
| 378 | + yield* persistence.rows.sourceRecipeOperations.listByRevisionId(initialRecipeRevisionId), | |
| 379 | + ).toEqual([]); | |
| 380 | + expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(nextRecipeId))).toBe(true); | |
| 381 | + expect( | |
| 382 | + Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(nextRecipeRevisionId)), | |
| 383 | + ).toBe(true); | |
| 384 | + }), | |
| 385 | + ); | |
| 386 | + | |
| 387 | + it.scoped("retains shared recipe data until the last source reference is removed", () => | |
| 388 | + Effect.gen(function* () { | |
| 389 | + const persistence = yield* makePersistence; | |
| 390 | + const now = Date.now(); | |
| 391 | + const accountId = AccountIdSchema.make("acc_shared_recipe"); | |
| 392 | + const organizationId = OrganizationIdSchema.make("org_shared_recipe"); | |
| 393 | + const workspaceId = WorkspaceIdSchema.make("ws_shared_recipe"); | |
| 394 | + const firstSourceId = SourceIdSchema.make("src_shared_recipe_one"); | |
| 395 | + const secondSourceId = SourceIdSchema.make("src_shared_recipe_two"); | |
| 396 | + | |
| 397 | + yield* persistence.rows.organizations.insert({ | |
| 398 | + id: organizationId, | |
| 399 | + slug: "shared-recipe", | |
| 400 | + name: "Shared Recipe", | |
| 401 | + status: "active", | |
| 402 | + createdByAccountId: accountId, | |
| 403 | + createdAt: now, | |
| 404 | + updatedAt: now, | |
| 405 | + }); | |
| 406 | + yield* persistence.rows.workspaces.insert({ | |
| 407 | + id: workspaceId, | |
| 408 | + organizationId, | |
| 409 | + name: "Primary", | |
| 410 | + createdByAccountId: accountId, | |
| 411 | + createdAt: now, | |
| 412 | + updatedAt: now, | |
| 413 | + }); | |
| 414 | + | |
| 415 | + const firstSource = makeOpenApiSource({ | |
| 416 | + workspaceId, | |
| 417 | + sourceId: firstSourceId, | |
| 418 | + now, | |
| 419 | + name: "GitHub One", | |
| 420 | + auth: { kind: "none" }, | |
| 421 | + }); | |
| 422 | + const secondSource = makeOpenApiSource({ | |
| 423 | + workspaceId, | |
| 424 | + sourceId: secondSourceId, | |
| 425 | + now, | |
| 426 | + updatedAt: now + 1, | |
| 427 | + name: "GitHub Two", | |
| 428 | + auth: { kind: "none" }, | |
| 429 | + }); | |
| 430 | + const sharedRecipeId = stableSourceRecipeId(firstSource); | |
| 431 | + const sharedRecipeRevisionId = stableSourceRecipeRevisionId(firstSource); | |
| 432 | + | |
| 433 | + yield* persistSource(persistence.rows, firstSource); | |
| 434 | + yield* persistSource(persistence.rows, secondSource); | |
| 435 | + yield* persistence.rows.sourceRecipeDocuments.replaceForRevision({ | |
| 436 | + recipeRevisionId: sharedRecipeRevisionId, | |
| 437 | + documents: [{ | |
| 438 | + id: "src_recipe_doc_shared_recipe", | |
| 439 | + recipeRevisionId: sharedRecipeRevisionId, | |
| 440 | + documentKind: "openapi", | |
| 441 | + documentKey: firstSource.specUrl ?? firstSource.endpoint, | |
| 442 | + contentText: "{}", | |
| 443 | + contentHash: "hash_shared_recipe", | |
| 444 | + fetchedAt: now, | |
| 445 | + createdAt: now, | |
| 446 | + updatedAt: now, | |
| 447 | + }], | |
| 448 | + }); | |
| 449 | + yield* persistence.rows.sourceRecipeOperations.replaceForRevision({ | |
| 450 | + recipeRevisionId: sharedRecipeRevisionId, | |
| 451 | + operations: [{ | |
| 452 | + id: "src_recipe_op_shared_recipe", | |
| 453 | + recipeRevisionId: sharedRecipeRevisionId, | |
| 454 | + operationKey: "getRepo", | |
| 455 | + transportKind: "http", | |
| 456 | + toolId: "getRepo", | |
| 457 | + title: "Get Repo", | |
| 458 | + description: "Read a repository", | |
| 459 | + operationKind: "read", | |
| 460 | + searchText: "github get repo", | |
| 461 | + inputSchemaJson: null, | |
| 462 | + outputSchemaJson: null, | |
| 463 | + providerKind: "openapi", | |
| 464 | + providerDataJson: null, | |
| 465 | + mcpToolName: null, | |
| 466 | + openApiMethod: "get", | |
| 467 | + openApiPathTemplate: "/repos/{owner}/{repo}", | |
| 468 | + openApiOperationHash: "hash_shared_recipe", | |
| 469 | + openApiRawToolId: "repos_getRepo", | |
| 470 | + openApiOperationId: "repos.getRepo", | |
| 471 | + openApiTagsJson: JSON.stringify(["repos"]), | |
| 472 | + openApiRequestBodyRequired: null, | |
| 473 | + graphqlOperationType: null, | |
| 474 | + graphqlOperationName: null, | |
| 475 | + createdAt: now, | |
| 476 | + updatedAt: now, | |
| 477 | + }], | |
| 478 | + }); | |
| 479 | + | |
| 480 | + yield* removeSourceById(persistence.rows, { | |
| 481 | + workspaceId, | |
| 482 | + sourceId: firstSourceId, | |
| 483 | + }); | |
| 484 | + | |
| 485 | + expect(Option.isSome(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true); | |
| 486 | + expect( | |
| 487 | + Option.isSome(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)), | |
| 488 | + ).toBe(true); | |
| 489 | + expect( | |
| 490 | + yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId), | |
| 491 | + ).toHaveLength(1); | |
| 492 | + expect( | |
| 493 | + yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId), | |
| 494 | + ).toHaveLength(1); | |
| 495 | + | |
| 496 | + yield* removeSourceById(persistence.rows, { | |
| 497 | + workspaceId, | |
| 498 | + sourceId: secondSourceId, | |
| 499 | + }); | |
| 500 | + | |
| 501 | + expect(Option.isNone(yield* persistence.rows.sourceRecipes.getById(sharedRecipeId))).toBe(true); | |
| 502 | + expect( | |
| 503 | + Option.isNone(yield* persistence.rows.sourceRecipeRevisions.getById(sharedRecipeRevisionId)), | |
| 504 | + ).toBe(true); | |
| 505 | + expect( | |
| 506 | + yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(sharedRecipeRevisionId), | |
| 507 | + ).toEqual([]); | |
| 508 | + expect( | |
| 509 | + yield* persistence.rows.sourceRecipeOperations.listByRevisionId(sharedRecipeRevisionId), | |
| 510 | + ).toEqual([]); | |
| 511 | + }), | |
| 512 | + ); | |
| 184 | 513 | }); |
| @@ -1,7 +1,10 @@ | ||
| 1 | 1 | import type { |
| 2 | + AccountId, | |
| 2 | 3 | Credential, |
| 3 | 4 | SecretRef, |
| 4 | 5 | Source, |
| 6 | + SourceRecipeId, | |
| 7 | + SourceRecipeRevisionId, | |
| 5 | 8 | WorkspaceId, |
| 6 | 9 | } from "#schema"; |
| 7 | 10 | import { type SqlControlPlaneRows } from "#persistence"; |
| @@ -9,8 +12,12 @@ import * as Effect from "effect/Effect"; · 1 unmodified line | ||
| 9 | 12 | import * as Option from "effect/Option"; |
| 10 | 13 | |
| 11 | 14 | import { |
| 15 | + createSourceRecipeRecord, | |
| 16 | + createSourceRecipeRevisionRecord, | |
| 12 | 17 | projectSourceFromStorage, |
| 13 | 18 | projectSourcesFromStorage, |
| 19 | + stableSourceRecipeId, | |
| 20 | + stableSourceRecipeRevisionId, | |
| 14 | 21 | splitSourceForStorage, |
| 15 | 22 | } from "./source-definitions"; |
| 16 | 23 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; |
| @@ -54,25 +61,57 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 37 unmodified lines | ||
| 54 | 61 | ); |
| 55 | 62 | }); |
| 56 | 63 | |
| 64 | +const selectPreferredCredential = (input: { | |
| 65 | + credentials: ReadonlyArray<Credential>; | |
| 66 | + actorAccountId?: AccountId | null; | |
| 67 | +}): Credential | null => { | |
| 68 | + if (input.actorAccountId !== undefined) { | |
| 69 | + const exact = input.credentials.find((credential) => credential.actorAccountId === input.actorAccountId); | |
| 70 | + if (exact) { | |
| 71 | + return exact; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + return input.credentials.find((credential) => credential.actorAccountId === null) ?? null; | |
| 76 | +}; | |
| 77 | + | |
| 78 | +const selectExactCredential = (input: { | |
| 79 | + credentials: ReadonlyArray<Credential>; | |
| 80 | + actorAccountId?: AccountId | null; | |
| 81 | +}): Credential | null => | |
| 82 | + input.credentials.find( | |
| 83 | + (credential) => credential.actorAccountId === (input.actorAccountId ?? null), | |
| 84 | + ) ?? null; | |
| 85 | + | |
| 57 | 86 | export const loadSourcesInWorkspace = ( |
| 58 | 87 | rows: SqlControlPlaneRows, |
| 59 | 88 | workspaceId: WorkspaceId, |
| 89 | + options: { | |
| 90 | + actorAccountId?: AccountId | null; | |
| 91 | + } = {}, | |
| 60 | 92 | ) => |
| 61 | 93 | Effect.gen(function* () { |
| 62 | 94 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); |
| 63 | − const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId); | |
| 64 | 95 | const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId); |
| 96 | + const filteredCredentials = sourceRecords.flatMap((sourceRecord) => { | |
| 97 | + const matches = credentials.filter((credential) => credential.sourceId === sourceRecord.id); | |
| 98 | + const preferred = selectPreferredCredential({ | |
| 99 | + credentials: matches, | |
| 100 | + actorAccountId: options.actorAccountId, | |
| 101 | + }); | |
| 102 | + return preferred ? [preferred] : []; | |
| 103 | + }); | |
| 65 | 104 | |
| 66 | 105 | return yield* projectSourcesFromStorage({ |
| 67 | 106 | sourceRecords, |
| 68 | − credentialBindings, | |
| 69 | − credentials, | |
| 107 | + credentials: filteredCredentials, | |
| 70 | 108 | }); |
| 71 | 109 | }); |
| 72 | 110 | |
| 73 | 111 | export const loadSourceById = (rows: SqlControlPlaneRows, input: { |
| 74 | 112 | workspaceId: WorkspaceId; |
| 75 | 113 | sourceId: Source["id"]; |
| 114 | + actorAccountId?: AccountId | null; | |
| 76 | 115 | }) => |
| 77 | 116 | Effect.gen(function* () { |
| 78 | 117 | const sourceRecord = yield* rows.sources.getByWorkspaceAndId( |
| @@ -86,53 +125,79 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 7 unmodified lines | ||
| 86 | 125 | ); |
| 87 | 126 | } |
| 88 | 127 | |
| 89 | − const credentialBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId( | |
| 90 | − input.workspaceId, | |
| 91 | − input.sourceId, | |
| 92 | − ); | |
| 93 | − const credential = | |
| 94 | − Option.isSome(credentialBinding) | |
| 95 | − ? yield* rows.credentials.getById(credentialBinding.value.credentialId) | |
| 96 | − : Option.none<Credential>(); | |
| 128 | + const credentials = yield* rows.credentials.listByWorkspaceAndSourceId({ | |
| 129 | + workspaceId: input.workspaceId, | |
| 130 | + sourceId: input.sourceId, | |
| 131 | + }); | |
| 132 | + const credential = selectPreferredCredential({ | |
| 133 | + credentials, | |
| 134 | + actorAccountId: input.actorAccountId, | |
| 135 | + }); | |
| 97 | 136 | |
| 98 | 137 | return yield* projectSourceFromStorage({ |
| 99 | 138 | sourceRecord: sourceRecord.value, |
| 100 | − credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null, | |
| 101 | − credential: Option.isSome(credential) ? credential.value : null, | |
| 139 | + credential, | |
| 102 | 140 | }); |
| 103 | 141 | }); |
| 104 | 142 | |
| 105 | −export const removeCredentialBindingForSource = (rows: SqlControlPlaneRows, input: { | |
| 143 | +const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: { | |
| 106 | 144 | workspaceId: WorkspaceId; |
| 107 | 145 | sourceId: Source["id"]; |
| 108 | 146 | }) => |
| 109 | 147 | Effect.gen(function* () { |
| 110 | − const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId( | |
| 111 | − input.workspaceId, | |
| 112 | − input.sourceId, | |
| 113 | − ); | |
| 114 | − | |
| 115 | − if (Option.isNone(existingBinding)) { | |
| 116 | − return false; | |
| 117 | − } | |
| 148 | + const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({ | |
| 149 | + workspaceId: input.workspaceId, | |
| 150 | + sourceId: input.sourceId, | |
| 151 | + }); | |
| 118 | 152 | |
| 119 | − const existingCredential = yield* rows.credentials.getById(existingBinding.value.credentialId); | |
| 153 | + yield* rows.credentials.removeByWorkspaceAndSourceId({ | |
| 154 | + workspaceId: input.workspaceId, | |
| 155 | + sourceId: input.sourceId, | |
| 156 | + }); | |
| 120 | 157 | |
| 121 | − yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId( | |
| 122 | − input.workspaceId, | |
| 123 | − input.sourceId, | |
| 158 | + yield* Effect.forEach( | |
| 159 | + existingCredentials, | |
| 160 | + (credential) => | |
| 161 | + cleanupCredentialSecretRefs(rows, { | |
| 162 | + previous: credential, | |
| 163 | + next: null, | |
| 164 | + }), | |
| 165 | + { discard: true }, | |
| 124 | 166 | ); |
| 125 | 167 | |
| 126 | − if (Option.isSome(existingCredential)) { | |
| 127 | − yield* rows.credentials.removeById(existingBinding.value.credentialId); | |
| 168 | + return existingCredentials.length; | |
| 169 | + }); | |
| 170 | + | |
| 171 | +const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { | |
| 172 | + recipeId: SourceRecipeId; | |
| 173 | + recipeRevisionId: SourceRecipeRevisionId; | |
| 174 | +}) => | |
| 175 | + Effect.gen(function* () { | |
| 176 | + const revisionReferenceCount = yield* rows.sources.countByRecipeRevisionId( | |
| 177 | + input.recipeRevisionId, | |
| 178 | + ); | |
| 179 | + if (revisionReferenceCount === 0) { | |
| 180 | + yield* rows.sourceRecipeDocuments.removeByRevisionId(input.recipeRevisionId); | |
| 181 | + yield* rows.sourceRecipeOperations.removeByRevisionId(input.recipeRevisionId); | |
| 128 | 182 | } |
| 129 | 183 | |
| 130 | − yield* cleanupCredentialSecretRefs(rows, { | |
| 131 | − previous: Option.isSome(existingCredential) ? existingCredential.value : null, | |
| 132 | − next: null, | |
| 133 | − }); | |
| 184 | + const recipeReferenceCount = yield* rows.sources.countByRecipeId(input.recipeId); | |
| 185 | + if (recipeReferenceCount > 0) { | |
| 186 | + return; | |
| 187 | + } | |
| 134 | 188 | |
| 135 | − return true; | |
| 189 | + const recipeRevisions = yield* rows.sourceRecipeRevisions.listByRecipeId(input.recipeId); | |
| 190 | + yield* Effect.forEach( | |
| 191 | + recipeRevisions, | |
| 192 | + (recipeRevision) => | |
| 193 | + Effect.all([ | |
| 194 | + rows.sourceRecipeDocuments.removeByRevisionId(recipeRevision.id), | |
| 195 | + rows.sourceRecipeOperations.removeByRevisionId(recipeRevision.id), | |
| 196 | + ]), | |
| 197 | + { discard: true }, | |
| 198 | + ); | |
| 199 | + yield* rows.sourceRecipeRevisions.removeByRecipeId(input.recipeId); | |
| 200 | + yield* rows.sourceRecipes.removeById(input.recipeId); | |
| 136 | 201 | }); |
| 137 | 202 | |
| 138 | 203 | export const removeSourceById = (rows: SqlControlPlaneRows, input: { |
| @@ -140,34 +205,81 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 1 unmodified line | ||
| 140 | 205 | sourceId: Source["id"]; |
| 141 | 206 | }) => |
| 142 | 207 | Effect.gen(function* () { |
| 208 | + const sourceRecord = yield* rows.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId); | |
| 209 | + if (Option.isNone(sourceRecord)) { | |
| 210 | + return false; | |
| 211 | + } | |
| 212 | + | |
| 143 | 213 | yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId( |
| 144 | 214 | input.workspaceId, |
| 145 | 215 | input.sourceId, |
| 146 | 216 | ); |
| 147 | − yield* removeCredentialBindingForSource(rows, input); | |
| 148 | − return yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId); | |
| 217 | + yield* rows.sourceOauthClients.removeByWorkspaceAndSourceId({ | |
| 218 | + workspaceId: input.workspaceId, | |
| 219 | + sourceId: input.sourceId, | |
| 220 | + }); | |
| 221 | + yield* removeCredentialsForSource(rows, input); | |
| 222 | + const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId); | |
| 223 | + if (!removed) { | |
| 224 | + return false; | |
| 225 | + } | |
| 226 | + | |
| 227 | + yield* cleanupOrphanedRecipeData(rows, { | |
| 228 | + recipeId: sourceRecord.value.recipeId, | |
| 229 | + recipeRevisionId: sourceRecord.value.recipeRevisionId, | |
| 230 | + }); | |
| 231 | + | |
| 232 | + return true; | |
| 149 | 233 | }); |
| 150 | 234 | |
| 151 | −export const persistSource = (rows: SqlControlPlaneRows, source: Source) => | |
| 235 | +export const persistSource = ( | |
| 236 | + rows: SqlControlPlaneRows, | |
| 237 | + source: Source, | |
| 238 | + options: { | |
| 239 | + actorAccountId?: AccountId | null; | |
| 240 | + } = {}, | |
| 241 | +) => | |
| 152 | 242 | Effect.gen(function* () { |
| 153 | 243 | const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id); |
| 154 | − const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId( | |
| 155 | − source.workspaceId, | |
| 156 | − source.id, | |
| 157 | − ); | |
| 158 | − const existingCredential = Option.isSome(existingBinding) | |
| 159 | − ? yield* rows.credentials.getById(existingBinding.value.credentialId) | |
| 160 | − : Option.none<Credential>(); | |
| 161 | − const existingCredentialId = Option.isSome(existingBinding) | |
| 162 | − ? existingBinding.value.credentialId | |
| 163 | − : null; | |
| 164 | − const existingBindingId = Option.isSome(existingBinding) | |
| 165 | − ? existingBinding.value.id | |
| 166 | − : null; | |
| 167 | − const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({ | |
| 244 | + const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({ | |
| 245 | + workspaceId: source.workspaceId, | |
| 246 | + sourceId: source.id, | |
| 247 | + }); | |
| 248 | + const existingCredential = selectExactCredential({ | |
| 249 | + credentials: existingCredentials, | |
| 250 | + actorAccountId: options.actorAccountId, | |
| 251 | + }); | |
| 252 | + | |
| 253 | + const nextRecipeId = stableSourceRecipeId(source); | |
| 254 | + const nextRecipeRevisionId = stableSourceRecipeRevisionId(source); | |
| 255 | + const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId); | |
| 256 | + const nextRevision = createSourceRecipeRevisionRecord({ | |
| 168 | 257 | source, |
| 169 | − existingCredentialId, | |
| 170 | − existingBindingId, | |
| 258 | + recipeId: nextRecipeId, | |
| 259 | + recipeRevisionId: nextRecipeRevisionId, | |
| 260 | + revisionNumber: Option.isSome(existingTargetRevision) | |
| 261 | + ? existingTargetRevision.value.revisionNumber | |
| 262 | + : 1, | |
| 263 | + manifestJson: Option.isSome(existingTargetRevision) | |
| 264 | + ? existingTargetRevision.value.manifestJson | |
| 265 | + : null, | |
| 266 | + manifestHash: Option.isSome(existingTargetRevision) | |
| 267 | + ? existingTargetRevision.value.manifestHash | |
| 268 | + : null, | |
| 269 | + }); | |
| 270 | + | |
| 271 | + const nextRecipe = createSourceRecipeRecord({ | |
| 272 | + source, | |
| 273 | + recipeId: nextRecipeId, | |
| 274 | + latestRevisionId: nextRevision.id, | |
| 275 | + }); | |
| 276 | + | |
| 277 | + const { sourceRecord, credential } = splitSourceForStorage({ | |
| 278 | + source, | |
| 279 | + recipeId: nextRecipe.id, | |
| 280 | + recipeRevisionId: nextRevision.id, | |
| 281 | + actorAccountId: options.actorAccountId, | |
| 282 | + existingCredentialId: existingCredential?.id ?? null, | |
| 171 | 283 | }); |
| 172 | 284 | |
| 173 | 285 | if (Option.isNone(existing)) { |
| @@ -177,30 +289,39 @@ export const persistSource = (rows: SqlControlPlaneRows, source: Source) => · 3 unmodified lines | ||
| 177 | 289 | id: _id, |
| 178 | 290 | workspaceId: _workspaceId, |
| 179 | 291 | createdAt: _createdAt, |
| 180 | − sourceDocumentText: _sourceDocumentText, | |
| 181 | 292 | ...patch |
| 182 | 293 | } = sourceRecord; |
| 183 | 294 | yield* rows.sources.update(source.workspaceId, source.id, patch); |
| 184 | 295 | } |
| 185 | 296 | |
| 186 | − if (credential === null || credentialBinding === null) { | |
| 187 | − if (Option.isSome(existingBinding)) { | |
| 188 | − yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId( | |
| 189 | − source.workspaceId, | |
| 190 | − source.id, | |
| 191 | − ); | |
| 192 | − | |
| 193 | − if (Option.isSome(existingCredential)) { | |
| 194 | − yield* rows.credentials.removeById(existingBinding.value.credentialId); | |
| 195 | − } | |
| 196 | − } | |
| 297 | + yield* rows.sourceRecipes.upsert(nextRecipe); | |
| 298 | + yield* rows.sourceRecipeRevisions.upsert(nextRevision); | |
| 299 | + | |
| 300 | + if ( | |
| 301 | + Option.isSome(existing) | |
| 302 | + && ( | |
| 303 | + existing.value.recipeId !== nextRecipeId | |
| 304 | + || existing.value.recipeRevisionId !== nextRecipeRevisionId | |
| 305 | + ) | |
| 306 | + ) { | |
| 307 | + yield* cleanupOrphanedRecipeData(rows, { | |
| 308 | + recipeId: existing.value.recipeId, | |
| 309 | + recipeRevisionId: existing.value.recipeRevisionId, | |
| 310 | + }); | |
| 311 | + } | |
| 312 | + | |
| 313 | + if (credential === null) { | |
| 314 | + yield* rows.credentials.removeByWorkspaceSourceAndActor({ | |
| 315 | + workspaceId: source.workspaceId, | |
| 316 | + sourceId: source.id, | |
| 317 | + actorAccountId: options.actorAccountId ?? null, | |
| 318 | + }); | |
| 197 | 319 | } else { |
| 198 | 320 | yield* rows.credentials.upsert(credential); |
| 199 | − yield* rows.sourceCredentialBindings.upsert(credentialBinding); | |
| 200 | 321 | } |
| 201 | 322 | |
| 202 | 323 | yield* cleanupCredentialSecretRefs(rows, { |
| 203 | − previous: Option.isSome(existingCredential) ? existingCredential.value : null, | |
| 324 | + previous: existingCredential ?? null, | |
| 204 | 325 | next: credential, |
| 205 | 326 | }); |
| 206 | 327 | |
| @@ -20,6 +20,22 @@ export { · 19 unmodified lines | ||
| 20 | 20 | type SourceStatus, |
| 21 | 21 | type SourceTransport, |
| 22 | 22 | } from "./models/source"; |
| 23 | +export { | |
| 24 | + SourceRecipeDocumentKindSchema, | |
| 25 | + SourceRecipeImporterKindSchema, | |
| 26 | + SourceRecipeKindSchema, | |
| 27 | + SourceRecipeOperationKindSchema, | |
| 28 | + SourceRecipeOperationProviderKindSchema, | |
| 29 | + SourceRecipeTransportKindSchema, | |
| 30 | + SourceRecipeVisibilitySchema, | |
| 31 | + type SourceRecipeDocumentKind, | |
| 32 | + type SourceRecipeImporterKind, | |
| 33 | + type SourceRecipeKind, | |
| 34 | + type SourceRecipeOperationKind, | |
| 35 | + type SourceRecipeOperationProviderKind, | |
| 36 | + type SourceRecipeTransportKind, | |
| 37 | + type SourceRecipeVisibility, | |
| 38 | +} from "./models/source-recipe"; | |
| 23 | 39 | export { |
| 24 | 40 | SourceAuthInferenceSchema, |
| 25 | 41 | SourceDiscoveryAuthKindSchema, |
| @@ -40,6 +56,12 @@ export { · 14 unmodified lines | ||
| 40 | 56 | CredentialAuthKindSchema, |
| 41 | 57 | type CredentialAuthKind, |
| 42 | 58 | } from "./models/credential"; |
| 59 | +export { | |
| 60 | + SourceAuthSessionProviderKindSchema, | |
| 61 | + SourceAuthSessionStatusSchema, | |
| 62 | + type SourceAuthSessionProviderKind, | |
| 63 | + type SourceAuthSessionStatus, | |
| 64 | +} from "./models/source-auth-session"; | |
| 43 | 65 | export { |
| 44 | 66 | PolicyApprovalModeSchema, |
| 45 | 67 | PolicyEffectSchema, |
| @@ -7,12 +7,19 @@ export const OrganizationMemberIdSchema = Schema.String.pipe( · 6 unmodified lines | ||
| 7 | 7 | ); |
| 8 | 8 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); |
| 9 | 9 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); |
| 10 | +export const SourceRecipeIdSchema = Schema.String.pipe(Schema.brand("SourceRecipeId")); | |
| 11 | +export const SourceRecipeRevisionIdSchema = Schema.String.pipe( | |
| 12 | + Schema.brand("SourceRecipeRevisionId"), | |
| 13 | +); | |
| 10 | 14 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 11 | 15 | Schema.brand("SourceAuthSessionId"), |
| 12 | 16 | ); |
| 13 | 17 | export const CredentialIdSchema = Schema.String.pipe( |
| 14 | 18 | Schema.brand("CredentialId"), |
| 15 | 19 | ); |
| 20 | +export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe( | |
| 21 | + Schema.brand("WorkspaceSourceOauthClientId"), | |
| 22 | +); | |
| 16 | 23 | export const SecretMaterialIdSchema = Schema.String.pipe( |
| 17 | 24 | Schema.brand("SecretMaterialId"), |
| 18 | 25 | ); |
| @@ -28,8 +35,11 @@ export type OrganizationId = typeof OrganizationIdSchema.Type; · 9 unmodified lines | ||
| 28 | 35 | export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; |
| 29 | 36 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 30 | 37 | export type SourceId = typeof SourceIdSchema.Type; |
| 38 | +export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; | |
| 39 | +export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; | |
| 31 | 40 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 32 | 41 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 42 | +export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; | |
| 33 | 43 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 34 | 44 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 35 | 45 | export type InstallationId = typeof InstallationIdSchema.Type; |
| @@ -5,7 +5,9 @@ export * from "./ids"; · 4 unmodified lines | ||
| 5 | 5 | export * from "./models/auth"; |
| 6 | 6 | export * from "./models/workspace"; |
| 7 | 7 | export * from "./models/source"; |
| 8 | +export * from "./models/source-recipe"; | |
| 8 | 9 | export * from "./models/credential"; |
| 10 | +export * from "./models/source-oauth-client"; | |
| 9 | 11 | export * from "./models/tool-artifact"; |
| 10 | 12 | export * from "./models/secret-material"; |
| 11 | 13 | export * from "./models/source-auth-session"; |
| @@ -1,12 +1,10 @@ | ||
| 1 | 1 | import { createSelectSchema } from "drizzle-orm/effect-schema"; |
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | |
| 4 | −import { | |
| 5 | − credentialsTable, | |
| 6 | − sourceCredentialBindingsTable, | |
| 7 | −} from "../../persistence/schema"; | |
| 4 | +import { credentialsTable } from "../../persistence/schema"; | |
| 8 | 5 | import { TimestampMsSchema } from "../common"; |
| 9 | 6 | import { |
| 7 | + AccountIdSchema, | |
| 10 | 8 | CredentialIdSchema, |
| 11 | 9 | SourceIdSchema, |
| 12 | 10 | WorkspaceIdSchema, |
| @@ -17,6 +15,8 @@ export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); · 4 unmodified lines | ||
| 17 | 15 | const credentialSchemaOverrides = { |
| 18 | 16 | id: CredentialIdSchema, |
| 19 | 17 | workspaceId: WorkspaceIdSchema, |
| 18 | + sourceId: SourceIdSchema, | |
| 19 | + actorAccountId: Schema.NullOr(AccountIdSchema), | |
| 20 | 20 | authKind: CredentialAuthKindSchema, |
| 21 | 21 | authHeaderName: Schema.String, |
| 22 | 22 | authPrefix: Schema.String, |
| @@ -28,25 +28,10 @@ const credentialSchemaOverrides = { · 5 unmodified lines | ||
| 28 | 28 | updatedAt: TimestampMsSchema, |
| 29 | 29 | } as const; |
| 30 | 30 | |
| 31 | −const sourceCredentialBindingSchemaOverrides = { | |
| 32 | − id: Schema.String, | |
| 33 | − workspaceId: WorkspaceIdSchema, | |
| 34 | − sourceId: SourceIdSchema, | |
| 35 | − credentialId: CredentialIdSchema, | |
| 36 | − createdAt: TimestampMsSchema, | |
| 37 | − updatedAt: TimestampMsSchema, | |
| 38 | −} as const; | |
| 39 | − | |
| 40 | 31 | export const CredentialSchema = createSelectSchema( |
| 41 | 32 | credentialsTable, |
| 42 | 33 | credentialSchemaOverrides, |
| 43 | 34 | ); |
| 44 | 35 | |
| 45 | −export const SourceCredentialBindingSchema = createSelectSchema( | |
| 46 | − sourceCredentialBindingsTable, | |
| 47 | − sourceCredentialBindingSchemaOverrides, | |
| 48 | −); | |
| 49 | − | |
| 50 | 36 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; |
| 51 | 37 | export type Credential = typeof CredentialSchema.Type; |
| 52 | −export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type; | |
| @@ -4,6 +4,7 @@ import { Schema } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import { sourceAuthSessionsTable } from "../../persistence/schema"; |
| 5 | 5 | import { TimestampMsSchema } from "../common"; |
| 6 | 6 | import { |
| 7 | + AccountIdSchema, | |
| 7 | 8 | ExecutionIdSchema, |
| 8 | 9 | ExecutionInteractionIdSchema, |
| 9 | 10 | SourceAuthSessionIdSchema, |
| @@ -11,8 +12,9 @@ import { · 1 unmodified line | ||
| 11 | 12 | WorkspaceIdSchema, |
| 12 | 13 | } from "../ids"; |
| 13 | 14 | |
| 14 | −export const SourceAuthSessionStrategySchema = Schema.Literal( | |
| 15 | − "oauth2_authorization_code", | |
| 15 | +export const SourceAuthSessionProviderKindSchema = Schema.Literal( | |
| 16 | + "mcp_oauth", | |
| 17 | + "oauth2_pkce", | |
| 16 | 18 | ); |
| 17 | 19 | |
| 18 | 20 | export const SourceAuthSessionStatusSchema = Schema.Literal( |
| @@ -26,9 +28,10 @@ const sourceAuthSessionSchemaOverrides = { · 7 unmodified lines | ||
| 26 | 28 | id: SourceAuthSessionIdSchema, |
| 27 | 29 | workspaceId: WorkspaceIdSchema, |
| 28 | 30 | sourceId: SourceIdSchema, |
| 31 | + actorAccountId: Schema.NullOr(AccountIdSchema), | |
| 29 | 32 | executionId: Schema.NullOr(ExecutionIdSchema), |
| 30 | 33 | interactionId: Schema.NullOr(ExecutionInteractionIdSchema), |
| 31 | − strategy: SourceAuthSessionStrategySchema, | |
| 34 | + providerKind: SourceAuthSessionProviderKindSchema, | |
| 32 | 35 | status: SourceAuthSessionStatusSchema, |
| 33 | 36 | completedAt: Schema.NullOr(TimestampMsSchema), |
| 34 | 37 | createdAt: TimestampMsSchema, |
| @@ -40,6 +43,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 5 unmodified lines | ||
| 40 | 43 | sourceAuthSessionSchemaOverrides, |
| 41 | 44 | ); |
| 42 | 45 | |
| 43 | −export type SourceAuthSessionStrategy = typeof SourceAuthSessionStrategySchema.Type; | |
| 46 | +export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type; | |
| 44 | 47 | export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; |
| 45 | 48 | export type SourceAuthSession = typeof SourceAuthSessionSchema.Type; |
| @@ -0,0 +1,23 @@ | ||
| 1 | +import { createSelectSchema } from "drizzle-orm/effect-schema"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | + | |
| 4 | +import { workspaceSourceOauthClientsTable } from "../../persistence/schema"; | |
| 5 | +import { TimestampMsSchema } from "../common"; | |
| 6 | +import { | |
| 7 | + SourceIdSchema, | |
| 8 | + WorkspaceIdSchema, | |
| 9 | + WorkspaceSourceOauthClientIdSchema, | |
| 10 | +} from "../ids"; | |
| 11 | + | |
| 12 | +export const WorkspaceSourceOauthClientSchema = createSelectSchema( | |
| 13 | + workspaceSourceOauthClientsTable, | |
| 14 | + { | |
| 15 | + id: WorkspaceSourceOauthClientIdSchema, | |
| 16 | + workspaceId: WorkspaceIdSchema, | |
| 17 | + sourceId: SourceIdSchema, | |
| 18 | + createdAt: TimestampMsSchema, | |
| 19 | + updatedAt: TimestampMsSchema, | |
| 20 | + } as const, | |
| 21 | +); | |
| 22 | + | |
| 23 | +export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type; | |
| @@ -0,0 +1,140 @@ | ||
| 1 | +import { createSelectSchema } from "drizzle-orm/effect-schema"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + sourceRecipeDocumentsTable, | |
| 6 | + sourceRecipeOperationsTable, | |
| 7 | + sourceRecipeRevisionsTable, | |
| 8 | + sourceRecipesTable, | |
| 9 | +} from "../../persistence/schema"; | |
| 10 | +import { TimestampMsSchema } from "../common"; | |
| 11 | +import { | |
| 12 | + SourceRecipeIdSchema, | |
| 13 | + SourceRecipeRevisionIdSchema, | |
| 14 | +} from "../ids"; | |
| 15 | + | |
| 16 | +export const SourceRecipeKindSchema = Schema.Literal( | |
| 17 | + "http_recipe", | |
| 18 | + "graphql_recipe", | |
| 19 | + "mcp_recipe", | |
| 20 | + "internal_recipe", | |
| 21 | +); | |
| 22 | + | |
| 23 | +export const SourceRecipeImporterKindSchema = Schema.Literal( | |
| 24 | + "openapi", | |
| 25 | + "google_discovery", | |
| 26 | + "postman_collection", | |
| 27 | + "snippet_bundle", | |
| 28 | + "graphql_introspection", | |
| 29 | + "mcp_manifest", | |
| 30 | + "internal_manifest", | |
| 31 | +); | |
| 32 | + | |
| 33 | +export const SourceRecipeVisibilitySchema = Schema.Literal( | |
| 34 | + "private", | |
| 35 | + "workspace", | |
| 36 | + "organization", | |
| 37 | + "public", | |
| 38 | +); | |
| 39 | + | |
| 40 | +export const SourceRecipeDocumentKindSchema = Schema.Literal( | |
| 41 | + "google_discovery", | |
| 42 | + "openapi", | |
| 43 | + "postman_collection", | |
| 44 | + "postman_environment", | |
| 45 | + "graphql_introspection", | |
| 46 | + "mcp_manifest", | |
| 47 | +); | |
| 48 | + | |
| 49 | +export const SourceRecipeTransportKindSchema = Schema.Literal( | |
| 50 | + "http", | |
| 51 | + "graphql", | |
| 52 | + "mcp", | |
| 53 | + "internal", | |
| 54 | +); | |
| 55 | + | |
| 56 | +export const SourceRecipeOperationKindSchema = Schema.Literal( | |
| 57 | + "read", | |
| 58 | + "write", | |
| 59 | + "delete", | |
| 60 | + "unknown", | |
| 61 | +); | |
| 62 | + | |
| 63 | +export const SourceRecipeOperationProviderKindSchema = Schema.Literal( | |
| 64 | + "mcp", | |
| 65 | + "openapi", | |
| 66 | + "graphql", | |
| 67 | + "internal", | |
| 68 | +); | |
| 69 | + | |
| 70 | +const recipeRowSchemaOverrides = { | |
| 71 | + id: SourceRecipeIdSchema, | |
| 72 | + kind: SourceRecipeKindSchema, | |
| 73 | + importerKind: SourceRecipeImporterKindSchema, | |
| 74 | + visibility: SourceRecipeVisibilitySchema, | |
| 75 | + latestRevisionId: SourceRecipeRevisionIdSchema, | |
| 76 | + createdAt: TimestampMsSchema, | |
| 77 | + updatedAt: TimestampMsSchema, | |
| 78 | +} as const; | |
| 79 | + | |
| 80 | +const recipeRevisionRowSchemaOverrides = { | |
| 81 | + id: SourceRecipeRevisionIdSchema, | |
| 82 | + recipeId: SourceRecipeIdSchema, | |
| 83 | + revisionNumber: Schema.Number, | |
| 84 | + createdAt: TimestampMsSchema, | |
| 85 | + updatedAt: TimestampMsSchema, | |
| 86 | +} as const; | |
| 87 | + | |
| 88 | +const recipeDocumentRowSchemaOverrides = { | |
| 89 | + recipeRevisionId: SourceRecipeRevisionIdSchema, | |
| 90 | + documentKind: SourceRecipeDocumentKindSchema, | |
| 91 | + fetchedAt: Schema.NullOr(TimestampMsSchema), | |
| 92 | + createdAt: TimestampMsSchema, | |
| 93 | + updatedAt: TimestampMsSchema, | |
| 94 | +} as const; | |
| 95 | + | |
| 96 | +const recipeOperationRowSchemaOverrides = { | |
| 97 | + recipeRevisionId: SourceRecipeRevisionIdSchema, | |
| 98 | + transportKind: SourceRecipeTransportKindSchema, | |
| 99 | + operationKind: SourceRecipeOperationKindSchema, | |
| 100 | + providerKind: SourceRecipeOperationProviderKindSchema, | |
| 101 | + openApiMethod: Schema.NullOr( | |
| 102 | + Schema.Literal("get", "put", "post", "delete", "patch", "head", "options", "trace"), | |
| 103 | + ), | |
| 104 | + createdAt: TimestampMsSchema, | |
| 105 | + updatedAt: TimestampMsSchema, | |
| 106 | +} as const; | |
| 107 | + | |
| 108 | +export const StoredSourceRecipeRecordSchema = createSelectSchema( | |
| 109 | + sourceRecipesTable, | |
| 110 | + recipeRowSchemaOverrides, | |
| 111 | +); | |
| 112 | + | |
| 113 | +export const StoredSourceRecipeRevisionRecordSchema = createSelectSchema( | |
| 114 | + sourceRecipeRevisionsTable, | |
| 115 | + recipeRevisionRowSchemaOverrides, | |
| 116 | +); | |
| 117 | + | |
| 118 | +export const StoredSourceRecipeDocumentRecordSchema = createSelectSchema( | |
| 119 | + sourceRecipeDocumentsTable, | |
| 120 | + recipeDocumentRowSchemaOverrides, | |
| 121 | +); | |
| 122 | + | |
| 123 | +export const StoredSourceRecipeOperationRecordSchema = createSelectSchema( | |
| 124 | + sourceRecipeOperationsTable, | |
| 125 | + recipeOperationRowSchemaOverrides, | |
| 126 | +); | |
| 127 | + | |
| 128 | +export type SourceRecipeKind = typeof SourceRecipeKindSchema.Type; | |
| 129 | +export type SourceRecipeImporterKind = typeof SourceRecipeImporterKindSchema.Type; | |
| 130 | +export type SourceRecipeVisibility = typeof SourceRecipeVisibilitySchema.Type; | |
| 131 | +export type SourceRecipeDocumentKind = typeof SourceRecipeDocumentKindSchema.Type; | |
| 132 | +export type SourceRecipeTransportKind = typeof SourceRecipeTransportKindSchema.Type; | |
| 133 | +export type SourceRecipeOperationKind = typeof SourceRecipeOperationKindSchema.Type; | |
| 134 | +export type SourceRecipeOperationProviderKind = | |
| 135 | + typeof SourceRecipeOperationProviderKindSchema.Type; | |
| 136 | +export type StoredSourceRecipeRecord = typeof StoredSourceRecipeRecordSchema.Type; | |
| 137 | +export type StoredSourceRecipeRevisionRecord = typeof StoredSourceRecipeRevisionRecordSchema.Type; | |
| 138 | +export type StoredSourceRecipeDocumentRecord = typeof StoredSourceRecipeDocumentRecordSchema.Type; | |
| 139 | +export type StoredSourceRecipeOperationRecord = | |
| 140 | + typeof StoredSourceRecipeOperationRecordSchema.Type; | |
| @@ -5,7 +5,12 @@ import { Schema } from "effect"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import { sourcesTable } from "../../persistence/schema"; |
| 7 | 7 | import { TimestampMsSchema } from "../common"; |
| 8 | −import { SourceIdSchema, WorkspaceIdSchema } from "../ids"; | |
| 8 | +import { | |
| 9 | + SourceIdSchema, | |
| 10 | + SourceRecipeIdSchema, | |
| 11 | + SourceRecipeRevisionIdSchema, | |
| 12 | + WorkspaceIdSchema, | |
| 13 | +} from "../ids"; | |
| 9 | 14 | |
| 10 | 15 | export const SourceKindSchema = Schema.Literal( |
| 11 | 16 | "mcp", |
| @@ -60,6 +65,8 @@ export const StringMapSchema = Schema.Record({ · 48 unmodified lines | ||
| 60 | 65 | const sourceRowSchemaOverrides = { |
| 61 | 66 | workspaceId: WorkspaceIdSchema, |
| 62 | 67 | sourceId: SourceIdSchema, |
| 68 | + recipeId: SourceRecipeIdSchema, | |
| 69 | + recipeRevisionId: SourceRecipeRevisionIdSchema, | |
| 63 | 70 | kind: SourceKindSchema, |
| 64 | 71 | status: SourceStatusSchema, |
| 65 | 72 | transport: Schema.NullOr(SourceTransportSchema), |
| @@ -74,12 +81,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines | ||
| 74 | 81 | Schema.Struct({ |
| 75 | 82 | id: SourceIdSchema, |
| 76 | 83 | workspaceId: WorkspaceIdSchema, |
| 84 | + recipeId: SourceRecipeIdSchema, | |
| 85 | + recipeRevisionId: SourceRecipeRevisionIdSchema, | |
| 77 | 86 | name: Schema.String, |
| 78 | 87 | kind: SourceKindSchema, |
| 79 | 88 | endpoint: Schema.String, |
| 80 | 89 | status: SourceStatusSchema, |
| 81 | 90 | enabled: Schema.Boolean, |
| 82 | 91 | namespace: Schema.NullOr(Schema.String), |
| 92 | + bindingConfigJson: Schema.NullOr(Schema.String), | |
| 83 | 93 | transport: Schema.NullOr(SourceTransportSchema), |
| 84 | 94 | queryParamsJson: Schema.NullOr(Schema.String), |
| 85 | 95 | headersJson: Schema.NullOr(Schema.String), |
| @@ -96,12 +106,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 10 unmodified lines | ||
| 96 | 106 | decode: (row, _input) => ({ |
| 97 | 107 | id: row.sourceId, |
| 98 | 108 | workspaceId: row.workspaceId, |
| 109 | + recipeId: row.recipeId, | |
| 110 | + recipeRevisionId: row.recipeRevisionId, | |
| 99 | 111 | name: row.name, |
| 100 | 112 | kind: row.kind, |
| 101 | 113 | endpoint: row.endpoint, |
| 102 | 114 | status: row.status, |
| 103 | 115 | enabled: row.enabled, |
| 104 | 116 | namespace: row.namespace, |
| 117 | + bindingConfigJson: row.bindingConfigJson, | |
| 105 | 118 | transport: row.transport, |
| 106 | 119 | queryParamsJson: row.queryParamsJson, |
| 107 | 120 | headersJson: row.headersJson, |
| @@ -116,12 +129,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines | ||
| 116 | 129 | encode: (source, _output) => ({ |
| 117 | 130 | workspaceId: source.workspaceId, |
| 118 | 131 | sourceId: source.id, |
| 132 | + recipeId: source.recipeId, | |
| 133 | + recipeRevisionId: source.recipeRevisionId, | |
| 119 | 134 | name: source.name, |
| 120 | 135 | kind: source.kind, |
| 121 | 136 | endpoint: source.endpoint, |
| 122 | 137 | status: source.status, |
| 123 | 138 | enabled: source.enabled, |
| 124 | 139 | namespace: source.namespace, |
| 140 | + bindingConfigJson: source.bindingConfigJson, | |
| 125 | 141 | transport: source.transport, |
| 126 | 142 | queryParamsJson: source.queryParamsJson, |
| 127 | 143 | headersJson: source.headersJson, |