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 | (handlers) => | 241 | (handlers) => |
| 242 | handlers | 242 | handlers |
| 243 | .handle("startSourceAuth", ({ path, payload }) => | 243 | .handle("startSourceAuth", ({ path, payload }) => |
| 244 | withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, () => | 244 | withWorkspaceRequestActor("oauth.start_source_auth", path.workspaceId, (actor) => |
| 245 | withPolicy(requireWriteSources(path.workspaceId))( | 245 | withPolicy(requireWriteSources(path.workspaceId))( |
| 246 | Effect.gen(function* () { | 246 | Effect.gen(function* () { |
| 247 | const request = yield* HttpServerRequest.HttpServerRequest; | 247 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 248 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | 248 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 249 | return yield* sourceAuthService.startSourceOAuthSession({ | 249 | return yield* sourceAuthService.startSourceOAuthSession({ |
| 250 | workspaceId: path.workspaceId, | 250 | workspaceId: path.workspaceId, |
| 251 | actorAccountId: actor.principal.accountId, | ||
| 251 | baseUrl: resolveRequestOrigin(request), | 252 | baseUrl: resolveRequestOrigin(request), |
| 252 | displayName: payload.name, | 253 | displayName: payload.name, |
| 253 | provider: { | 254 | provider: { |
| @@ -675,7 +675,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 674 unmodified lines | |||
| 675 | ), | 675 | ), |
| 676 | ) | 676 | ) |
| 677 | .handle("connect", ({ path, payload }) => | 677 | .handle("connect", ({ path, payload }) => |
| 678 | withWorkspaceRequestActor("sources.connect", path.workspaceId, () => | 678 | withWorkspaceRequestActor("sources.connect", path.workspaceId, (actor) => |
| 679 | withPolicy(requireWriteSources(path.workspaceId))( | 679 | withPolicy(requireWriteSources(path.workspaceId))( |
| 680 | Effect.gen(function* () { | 680 | Effect.gen(function* () { |
| 681 | const request = yield* HttpServerRequest.HttpServerRequest; | 681 | const request = yield* HttpServerRequest.HttpServerRequest; |
| @@ -685,6 +685,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 3 unmodified lines | |||
| 685 | if (payload.kind === "mcp") { | 685 | if (payload.kind === "mcp") { |
| 686 | return yield* sourceAuthService.connectMcpSource({ | 686 | return yield* sourceAuthService.connectMcpSource({ |
| 687 | workspaceId: path.workspaceId, | 687 | workspaceId: path.workspaceId, |
| 688 | actorAccountId: actor.principal.accountId, | ||
| 688 | endpoint: payload.endpoint, | 689 | endpoint: payload.endpoint, |
| 689 | name: payload.name, | 690 | name: payload.name, |
| 690 | namespace: payload.namespace, | 691 | namespace: payload.namespace, |
| @@ -699,6 +700,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 8 unmodified lines | |||
| 699 | return yield* sourceAuthService.addExecutorSource( | 700 | return yield* sourceAuthService.addExecutorSource( |
| 700 | { | 701 | { |
| 701 | workspaceId: path.workspaceId, | 702 | workspaceId: path.workspaceId, |
| 703 | actorAccountId: actor.principal.accountId, | ||
| 702 | executionId: null, | 704 | executionId: null, |
| 703 | interactionId: null, | 705 | interactionId: null, |
| 704 | kind: "openapi", | 706 | kind: "openapi", |
| @@ -715,6 +717,7 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 10 unmodified lines | |||
| 715 | return yield* sourceAuthService.addExecutorSource( | 717 | return yield* sourceAuthService.addExecutorSource( |
| 716 | { | 718 | { |
| 717 | workspaceId: path.workspaceId, | 719 | workspaceId: path.workspaceId, |
| 720 | actorAccountId: actor.principal.accountId, | ||
| 718 | executionId: null, | 721 | executionId: null, |
| 719 | interactionId: null, | 722 | interactionId: null, |
| 720 | kind: "graphql", | 723 | kind: "graphql", |
| @@ -9,17 +9,22 @@ import { · 8 unmodified lines | |||
| 9 | SecretMaterialIdSchema, | 9 | SecretMaterialIdSchema, |
| 10 | SourceAuthSessionIdSchema, | 10 | SourceAuthSessionIdSchema, |
| 11 | SourceIdSchema, | 11 | SourceIdSchema, |
| 12 | SourceRecipeIdSchema, | ||
| 13 | SourceRecipeRevisionIdSchema, | ||
| 12 | WorkspaceIdSchema, | 14 | WorkspaceIdSchema, |
| 13 | } from "#schema"; | 15 | } from "#schema"; |
| 14 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 15 | import * as Option from "effect/Option"; | 17 | import * as Option from "effect/Option"; |
| 18 | import * as Scope from "effect/Scope"; | ||
| 16 | 19 | ||
| 17 | import { | 20 | import { |
| 18 | createSqlControlPlanePersistence, | 21 | createSqlControlPlanePersistence, |
| 19 | type SqlControlPlanePersistence, | 22 | type SqlControlPlanePersistence, |
| 20 | } from "./index"; | 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 | createSqlControlPlanePersistence({ | 28 | createSqlControlPlanePersistence({ |
| 24 | localDataDir: ":memory:", | 29 | localDataDir: ":memory:", |
| 25 | }), | 30 | }), |
| @@ -28,20 +33,19 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines | |||
| 28 | try: () => persistence.close(), | 33 | try: () => persistence.close(), |
| 29 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | 34 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), |
| 30 | }).pipe(Effect.orDie), | 35 | }).pipe(Effect.orDie), |
| 31 | ); | 36 | ); |
| 32 | 37 | ||
| 33 | const seedWorkspaceCredentialState = (input: { | 38 | const seedWorkspaceSourceState = (input: { |
| 34 | persistence: SqlControlPlanePersistence; | 39 | persistence: SqlControlPlanePersistence; |
| 35 | accountId: ReturnType<typeof AccountIdSchema.make>; | 40 | accountId: ReturnType<typeof AccountIdSchema.make>; |
| 36 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; | 41 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; |
| 37 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; | 42 | workspaceId: ReturnType<typeof WorkspaceIdSchema.make>; |
| 38 | sourceId: ReturnType<typeof SourceIdSchema.make>; | 43 | sourceId: ReturnType<typeof SourceIdSchema.make>; |
| 39 | }) => | 44 | }): Effect.Effect<void, unknown, never> => |
| 40 | Effect.gen(function* () { | 45 | Effect.gen(function* () { |
| 41 | const now = Date.now(); | 46 | const now = Date.now(); |
| 42 | const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`); | 47 | const recipeId = SourceRecipeIdSchema.make(`src_recipe_${input.sourceId}`); |
| 43 | const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`); | 48 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${input.sourceId}`); |
| 44 | const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`); | ||
| 45 | 49 | ||
| 46 | yield* input.persistence.rows.organizations.insert({ | 50 | yield* input.persistence.rows.organizations.insert({ |
| 47 | id: input.organizationId, | 51 | id: input.organizationId, |
| @@ -63,6 +67,8 @@ const seedWorkspaceCredentialState = (input: { · 15 unmodified lines | |||
| 63 | yield* input.persistence.rows.sources.insert({ | 67 | yield* input.persistence.rows.sources.insert({ |
| 64 | id: input.sourceId, | 68 | id: input.sourceId, |
| 65 | workspaceId: input.workspaceId, | 69 | workspaceId: input.workspaceId, |
| 70 | recipeId, | ||
| 71 | recipeRevisionId, | ||
| 66 | name: "Github", | 72 | name: "Github", |
| 67 | kind: "openapi", | 73 | kind: "openapi", |
| 68 | endpoint: "https://api.github.com", | 74 | endpoint: "https://api.github.com", |
| @@ -70,6 +76,7 @@ const seedWorkspaceCredentialState = (input: { · 1 unmodified line | |||
| 70 | enabled: true, | 76 | enabled: true, |
| 71 | namespace: "github", | 77 | namespace: "github", |
| 72 | transport: null, | 78 | transport: null, |
| 79 | bindingConfigJson: null, | ||
| 73 | queryParamsJson: null, | 80 | queryParamsJson: null, |
| 74 | headersJson: null, | 81 | headersJson: null, |
| 75 | specUrl: "https://api.github.com/openapi.json", | 82 | specUrl: "https://api.github.com/openapi.json", |
| @@ -80,6 +87,25 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines | |||
| 80 | createdAt: now, | 87 | createdAt: now, |
| 81 | updatedAt: now, | 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 | yield* input.persistence.rows.secretMaterials.upsert({ | 109 | yield* input.persistence.rows.secretMaterials.upsert({ |
| 84 | id: tokenId, | 110 | id: tokenId, |
| 85 | name: null, | 111 | name: null, |
| @@ -99,6 +125,8 @@ const seedWorkspaceCredentialState = (input: { · 13 unmodified lines | |||
| 99 | yield* input.persistence.rows.credentials.upsert({ | 125 | yield* input.persistence.rows.credentials.upsert({ |
| 100 | id: credentialId, | 126 | id: credentialId, |
| 101 | workspaceId: input.workspaceId, | 127 | workspaceId: input.workspaceId, |
| 128 | sourceId: input.sourceId, | ||
| 129 | actorAccountId: input.accountId, | ||
| 102 | authKind: "oauth2", | 130 | authKind: "oauth2", |
| 103 | authHeaderName: "Authorization", | 131 | authHeaderName: "Authorization", |
| 104 | authPrefix: "Bearer ", | 132 | authPrefix: "Bearer ", |
| @@ -109,33 +137,29 @@ const seedWorkspaceCredentialState = (input: { · 4 unmodified lines | |||
| 109 | createdAt: now, | 137 | createdAt: now, |
| 110 | updatedAt: now, | 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 | yield* input.persistence.rows.sourceAuthSessions.upsert({ | 140 | yield* input.persistence.rows.sourceAuthSessions.upsert({ |
| 121 | id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`), | 141 | id: SourceAuthSessionIdSchema.make(`src_auth_${input.workspaceId}`), |
| 122 | workspaceId: input.workspaceId, | 142 | workspaceId: input.workspaceId, |
| 123 | sourceId: input.sourceId, | 143 | sourceId: input.sourceId, |
| 144 | actorAccountId: input.accountId, | ||
| 124 | executionId: null, | 145 | executionId: null, |
| 125 | interactionId: null, | 146 | interactionId: null, |
| 126 | strategy: "oauth2_authorization_code", | 147 | providerKind: "mcp_oauth", |
| 127 | status: "pending", | 148 | status: "pending", |
| 128 | endpoint: "https://api.github.com", | ||
| 129 | state: `state_${input.workspaceId}`, | 149 | state: `state_${input.workspaceId}`, |
| 130 | redirectUri: "http://127.0.0.1/callback", | 150 | sessionDataJson: JSON.stringify({ |
| 131 | scope: null, | 151 | kind: "mcp_oauth", |
| 132 | resourceMetadataUrl: null, | 152 | endpoint: "https://api.github.com", |
| 133 | authorizationServerUrl: null, | 153 | redirectUri: "http://127.0.0.1/callback", |
| 134 | resourceMetadataJson: null, | 154 | scope: null, |
| 135 | authorizationServerMetadataJson: null, | 155 | resourceMetadataUrl: null, |
| 136 | clientInformationJson: null, | 156 | authorizationServerUrl: null, |
| 137 | codeVerifier: "verifier", | 157 | resourceMetadataJson: null, |
| 138 | authorizationUrl: "https://example.com/auth", | 158 | authorizationServerMetadataJson: null, |
| 159 | clientInformationJson: null, | ||
| 160 | codeVerifier: "verifier", | ||
| 161 | authorizationUrl: "https://example.com/auth", | ||
| 162 | }), | ||
| 139 | errorText: null, | 163 | errorText: null, |
| 140 | completedAt: null, | 164 | completedAt: null, |
| 141 | createdAt: now, | 165 | createdAt: now, |
| @@ -176,6 +200,8 @@ describe("control-plane-persistence-drizzle", () => { · 34 unmodified lines | |||
| 176 | yield* persistence.rows.sources.insert({ | 200 | yield* persistence.rows.sources.insert({ |
| 177 | id: SourceIdSchema.make("src_1"), | 201 | id: SourceIdSchema.make("src_1"), |
| 178 | workspaceId, | 202 | workspaceId, |
| 203 | recipeId: SourceRecipeIdSchema.make("src_recipe_1"), | ||
| 204 | recipeRevisionId: SourceRecipeRevisionIdSchema.make("src_recipe_rev_1"), | ||
| 179 | name: "Github", | 205 | name: "Github", |
| 180 | kind: "openapi", | 206 | kind: "openapi", |
| 181 | endpoint: "https://api.github.com", | 207 | endpoint: "https://api.github.com", |
| @@ -183,6 +209,7 @@ describe("control-plane-persistence-drizzle", () => { · 1 unmodified line | |||
| 183 | enabled: true, | 209 | enabled: true, |
| 184 | namespace: "github", | 210 | namespace: "github", |
| 185 | transport: null, | 211 | transport: null, |
| 212 | bindingConfigJson: null, | ||
| 186 | queryParamsJson: null, | 213 | queryParamsJson: null, |
| 187 | headersJson: null, | 214 | headersJson: null, |
| 188 | specUrl: "https://api.github.com/openapi.json", | 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 | it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () => | 432 | it.scoped("deleting a workspace removes source credentials, sessions, and postgres secrets", () => |
| 275 | Effect.gen(function* () { | 433 | Effect.gen(function* () { |
| 276 | const persistence = yield* makePersistence; | 434 | const persistence = yield* makePersistence; |
| @@ -291,7 +449,6 @@ describe("control-plane-persistence-drizzle", () => { · 14 unmodified lines | |||
| 291 | expect(removed).toBe(true); | 449 | expect(removed).toBe(true); |
| 292 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); | 450 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 293 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); | 451 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 294 | expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0); | ||
| 295 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 452 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 296 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); | 453 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 297 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); | 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 | expect(removed).toBe(true); | 475 | expect(removed).toBe(true); |
| 319 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); | 476 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 320 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); | 477 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 321 | expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0); | ||
| 322 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 478 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 323 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); | 479 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 324 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); | 480 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); |
| @@ -15,7 +15,11 @@ import { · 14 unmodified lines | |||
| 15 | createPoliciesRepo, | 15 | createPoliciesRepo, |
| 16 | createSecretMaterialsRepo, | 16 | createSecretMaterialsRepo, |
| 17 | createSourceAuthSessionsRepo, | 17 | createSourceAuthSessionsRepo, |
| 18 | createSourceCredentialBindingsRepo, | 18 | createSourceOauthClientsRepo, |
| 19 | createSourceRecipeDocumentsRepo, | ||
| 20 | createSourceRecipeOperationsRepo, | ||
| 21 | createSourceRecipeRevisionsRepo, | ||
| 22 | createSourceRecipesRepo, | ||
| 19 | createSourcesRepo, | 23 | createSourcesRepo, |
| 20 | createToolArtifactsRepo, | 24 | createToolArtifactsRepo, |
| 21 | createWorkspacesRepo, | 25 | createWorkspacesRepo, |
| @@ -29,6 +33,7 @@ import { · 7 unmodified lines | |||
| 29 | type SqlBackend, | 33 | type SqlBackend, |
| 30 | type SqlRuntime, | 34 | type SqlRuntime, |
| 31 | } from "./sql-runtime"; | 35 | } from "./sql-runtime"; |
| 36 | import { runPostMigrationRepairs } from "./post-migrations"; | ||
| 32 | 37 | ||
| 33 | export { tableNames, type DrizzleTables } from "./schema"; | 38 | export { tableNames, type DrizzleTables } from "./schema"; |
| 34 | export { | 39 | export { |
| @@ -52,9 +57,13 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 17 unmodified lines | |||
| 52 | organizationMemberships: createOrganizationMembershipsRepo(client, tables), | 57 | organizationMemberships: createOrganizationMembershipsRepo(client, tables), |
| 53 | workspaces: createWorkspacesRepo(client, tables), | 58 | workspaces: createWorkspacesRepo(client, tables), |
| 54 | sources: createSourcesRepo(client, tables), | 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 | credentials: createCredentialsRepo(client, tables), | 64 | credentials: createCredentialsRepo(client, tables), |
| 65 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), | ||
| 56 | toolArtifacts: createToolArtifactsRepo(client, tables), | 66 | toolArtifacts: createToolArtifactsRepo(client, tables), |
| 57 | sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables), | ||
| 58 | secretMaterials: createSecretMaterialsRepo(client, tables), | 67 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 59 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), | 68 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| 60 | policies: createPoliciesRepo(client, tables), | 69 | policies: createPoliciesRepo(client, tables), |
| @@ -127,14 +136,20 @@ export const createSqlControlPlanePersistence = ( · 66 unmodified lines | |||
| 127 | backend: runtime.backend, | 136 | backend: runtime.backend, |
| 128 | db: runtime.db, | 137 | db: runtime.db, |
| 129 | }); | 138 | }); |
| 139 | const rows = createRows(client); | ||
| 130 | 140 | ||
| 131 | return { | 141 | return { |
| 132 | backend: runtime.backend, | 142 | backend: runtime.backend, |
| 133 | db: runtime.db, | 143 | db: runtime.db, |
| 134 | rows: createRows(client), | 144 | rows, |
| 135 | close: () => runtime.close(), | 145 | close: () => runtime.close(), |
| 136 | } satisfies SqlControlPlanePersistence; | 146 | } satisfies SqlControlPlanePersistence; |
| 137 | }), | 147 | }), |
| 148 | Effect.flatMap((persistence) => | ||
| 149 | runPostMigrationRepairs(persistence.rows).pipe( | ||
| 150 | Effect.mapError(toBootstrapError), | ||
| 151 | Effect.map(() => persistence), | ||
| 152 | )), | ||
| 138 | Effect.catchAll((error) => | 153 | Effect.catchAll((error) => |
| 139 | closeRuntimeEffect(runtime).pipe( | 154 | closeRuntimeEffect(runtime).pipe( |
| 140 | Effect.zipRight(Effect.fail(error)), | 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 | } from "#schema"; | 4 | } from "#schema"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import { Schema } from "effect"; | 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 | import type { DrizzleClient } from "../client"; | 9 | import type { DrizzleClient } from "../client"; |
| 10 | import type { DrizzleTables } from "../schema"; | 10 | import type { DrizzleTables } from "../schema"; |
| 11 | import { firstOption, withoutCreatedAt } from "./shared"; | 11 | import { firstOption } from "./shared"; |
| 12 | 12 | ||
| 13 | const decodeCredential = Schema.decodeUnknownSync(CredentialSchema); | 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 | export const createCredentialsRepo = ( | 27 | export const createCredentialsRepo = ( |
| 16 | client: DrizzleClient, | 28 | client: DrizzleClient, |
| 17 | tables: DrizzleTables, | 29 | tables: DrizzleTables, |
| @@ -30,12 +42,78 @@ export const createCredentialsRepo = ( · 12 unmodified lines | |||
| 30 | return rows.map((row) => decodeCredential(row)); | 42 | return rows.map((row) => decodeCredential(row)); |
| 31 | }), | 43 | }), |
| 32 | 44 | ||
| 33 | getById: (id: Credential["id"]) => | 45 | listByWorkspaceAndSourceId: (input: { |
| 34 | client.use("rows.credentials.get_by_id", async (db) => { | 46 | workspaceId: Credential["workspaceId"]; |
| 47 | sourceId: Credential["sourceId"]; | ||
| 48 | }) => | ||
| 49 | client.use("rows.credentials.list_by_workspace_source", async (db) => { | ||
| 35 | const rows = await db | 50 | const rows = await db |
| 36 | .select() | 51 | .select() |
| 37 | .from(tables.credentialsTable) | 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 | .limit(1); | 117 | .limit(1); |
| 40 | 118 | ||
| 41 | const row = firstOption(rows); | 119 | const row = firstOption(rows); |
| @@ -46,24 +124,96 @@ export const createCredentialsRepo = ( · 4 unmodified lines | |||
| 46 | 124 | ||
| 47 | upsert: (credential: Credential) => | 125 | upsert: (credential: Credential) => |
| 48 | client.use("rows.credentials.upsert", async (db) => { | 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 | await db | 165 | await db |
| 50 | .insert(tables.credentialsTable) | 166 | .insert(tables.credentialsTable) |
| 51 | .values(credential) | 167 | .values(credential) |
| 52 | .onConflictDoUpdate({ | 168 | .onConflictDoUpdate({ |
| 53 | target: [tables.credentialsTable.id], | 169 | target: [ |
| 170 | tables.credentialsTable.workspaceId, | ||
| 171 | tables.credentialsTable.sourceId, | ||
| 172 | tables.credentialsTable.actorAccountId, | ||
| 173 | ], | ||
| 54 | set: { | 174 | set: { |
| 55 | ...withoutCreatedAt(credential), | 175 | ...credentialUpdateSet(credential), |
| 56 | }, | 176 | }, |
| 57 | }); | 177 | }); |
| 58 | }), | 178 | }), |
| 59 | 179 | ||
| 60 | removeById: (id: Credential["id"]) => | 180 | removeByWorkspaceSourceAndActor: (input: { |
| 61 | client.use("rows.credentials.remove", async (db) => { | 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 | const deleted = await db | 186 | const deleted = await db |
| 63 | .delete(tables.credentialsTable) | 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 | .returning(); | 197 | .returning(); |
| 66 | 198 | ||
| 67 | return deleted.length > 0; | 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 | export { createOrganizationMembershipsRepo } from "./organization-memberships-repo"; | 3 | export { createOrganizationMembershipsRepo } from "./organization-memberships-repo"; |
| 4 | export { createWorkspacesRepo } from "./workspaces-repo"; | 4 | export { createWorkspacesRepo } from "./workspaces-repo"; |
| 5 | export { createSourcesRepo } from "./sources-repo"; | 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 | export { createCredentialsRepo } from "./credentials-repo"; | 10 | export { createCredentialsRepo } from "./credentials-repo"; |
| 11 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; | ||
| 7 | export { createToolArtifactsRepo } from "./tool-artifacts-repo"; | 12 | export { createToolArtifactsRepo } from "./tool-artifacts-repo"; |
| 8 | export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo"; | ||
| 9 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; | 13 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 10 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; | 14 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| 11 | export { createPoliciesRepo } from "./policies-repo"; | 15 | export { createPoliciesRepo } from "./policies-repo"; |
| @@ -6,7 +6,7 @@ import { · 5 unmodified lines | |||
| 6 | } from "#schema"; | 6 | } from "#schema"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import { Schema } from "effect"; | 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 | import type { DrizzleClient } from "../client"; | 11 | import type { DrizzleClient } from "../client"; |
| 12 | import type { DrizzleTables } from "../schema"; | 12 | import type { DrizzleTables } from "../schema"; |
| @@ -136,6 +136,13 @@ export const createOrganizationsRepo = ( · 123 unmodified lines | |||
| 136 | .select({ id: tables.executionsTable.id }) | 136 | .select({ id: tables.executionsTable.id }) |
| 137 | .from(tables.executionsTable) | 137 | .from(tables.executionsTable) |
| 138 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); | 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 | const credentials = await tx | 146 | const credentials = await tx |
| 140 | .select({ | 147 | .select({ |
| 141 | tokenProviderId: tables.credentialsTable.tokenProviderId, | 148 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| @@ -147,6 +154,28 @@ export const createOrganizationsRepo = ( · 5 unmodified lines | |||
| 147 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | 154 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); |
| 148 | 155 | ||
| 149 | const executionIds = executionRows.map((execution) => execution.id); | 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 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | 179 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 151 | 180 | ||
| 152 | if (executionIds.length > 0) { | 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 | await tx | 222 | await tx |
| 161 | .delete(tables.executionsTable) | 223 | .delete(tables.executionsTable) |
| 162 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); | 224 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| @@ -166,17 +228,41 @@ export const createOrganizationsRepo = ( · 3 unmodified lines | |||
| 166 | .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds)); | 228 | .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds)); |
| 167 | 229 | ||
| 168 | await tx | 230 | await tx |
| 169 | .delete(tables.sourceCredentialBindingsTable) | 231 | .delete(tables.workspaceSourceOauthClientsTable) |
| 170 | .where(inArray(tables.sourceCredentialBindingsTable.workspaceId, workspaceIds)); | 232 | .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds)); |
| 171 | 233 | ||
| 172 | await tx | 234 | await tx |
| 173 | .delete(tables.credentialsTable) | 235 | .delete(tables.credentialsTable) |
| 174 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | 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 | await tx | 242 | await tx |
| 177 | .delete(tables.sourcesTable) | 243 | .delete(tables.sourcesTable) |
| 178 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | 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 | await tx | 266 | await tx |
| 181 | .delete(tables.policiesTable) | 267 | .delete(tables.policiesTable) |
| 182 | .where(inArray(tables.policiesTable.workspaceId, workspaceIds)); | 268 | .where(inArray(tables.policiesTable.workspaceId, workspaceIds)); |
| @@ -105,19 +105,15 @@ export const createSecretMaterialsRepo = ( · 104 unmodified lines | |||
| 105 | tokenProviderId: tables.credentialsTable.tokenProviderId, | 105 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| 106 | refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | 106 | refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, |
| 107 | refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | 107 | refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, |
| 108 | sourceId: tables.sourceCredentialBindingsTable.sourceId, | 108 | sourceId: tables.credentialsTable.sourceId, |
| 109 | sourceName: tables.sourcesTable.name, | 109 | sourceName: tables.sourcesTable.name, |
| 110 | }) | 110 | }) |
| 111 | .from(tables.credentialsTable) | 111 | .from(tables.credentialsTable) |
| 112 | .innerJoin( | ||
| 113 | tables.sourceCredentialBindingsTable, | ||
| 114 | eq(tables.credentialsTable.id, tables.sourceCredentialBindingsTable.credentialId), | ||
| 115 | ) | ||
| 116 | .innerJoin( | 112 | .innerJoin( |
| 117 | tables.sourcesTable, | 113 | tables.sourcesTable, |
| 118 | and( | 114 | and( |
| 119 | eq(tables.sourceCredentialBindingsTable.workspaceId, tables.sourcesTable.workspaceId), | 115 | eq(tables.credentialsTable.workspaceId, tables.sourcesTable.workspaceId), |
| 120 | eq(tables.sourceCredentialBindingsTable.sourceId, tables.sourcesTable.sourceId), | 116 | eq(tables.credentialsTable.sourceId, tables.sourcesTable.sourceId), |
| 121 | ), | 117 | ), |
| 122 | ) | 118 | ) |
| 123 | .where( | 119 | .where( |
| @@ -4,7 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | } from "#schema"; | 4 | } from "#schema"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import { Schema } from "effect"; | 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 | import type { DrizzleClient } from "../client"; | 9 | import type { DrizzleClient } from "../client"; |
| 10 | import type { DrizzleTables } from "../schema"; | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -60,29 +60,39 @@ export const createSourceAuthSessionsRepo = ( · 49 unmodified lines | |||
| 60 | : Option.none<SourceAuthSession>(); | 60 | : Option.none<SourceAuthSession>(); |
| 61 | }), | 61 | }), |
| 62 | 62 | ||
| 63 | getPendingByWorkspaceAndSourceId: ( | 63 | getPendingByWorkspaceSourceAndActor: (input: { |
| 64 | workspaceId: SourceAuthSession["workspaceId"], | 64 | workspaceId: SourceAuthSession["workspaceId"]; |
| 65 | sourceId: SourceAuthSession["sourceId"], | 65 | sourceId: SourceAuthSession["sourceId"]; |
| 66 | ) => | 66 | actorAccountId: SourceAuthSession["actorAccountId"]; |
| 67 | client.use("rows.source_auth_sessions.get_pending_by_workspace_and_source_id", async (db) => { | 67 | }) => |
| 68 | const rows = await db | 68 | client.use( |
| 69 | .select() | 69 | "rows.source_auth_sessions.get_pending_by_workspace_source_actor", |
| 70 | .from(tables.sourceAuthSessionsTable) | 70 | async (db) => { |
| 71 | .where( | 71 | const rows = await db |
| 72 | and( | 72 | .select() |
| 73 | eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId), | 73 | .from(tables.sourceAuthSessionsTable) |
| 74 | eq(tables.sourceAuthSessionsTable.sourceId, sourceId), | 74 | .where( |
| 75 | eq(tables.sourceAuthSessionsTable.status, "pending"), | 75 | and( |
| 76 | ), | 76 | eq(tables.sourceAuthSessionsTable.workspaceId, input.workspaceId), |
| 77 | ) | 77 | eq(tables.sourceAuthSessionsTable.sourceId, input.sourceId), |
| 78 | .orderBy(asc(tables.sourceAuthSessionsTable.updatedAt), asc(tables.sourceAuthSessionsTable.id)) | 78 | input.actorAccountId === null |
| 79 | .limit(1); | 79 | ? isNull(tables.sourceAuthSessionsTable.actorAccountId) |
| 80 | 80 | : eq(tables.sourceAuthSessionsTable.actorAccountId, input.actorAccountId), | |
| 81 | const row = firstOption(rows); | 81 | eq(tables.sourceAuthSessionsTable.status, "pending"), |
| 82 | return Option.isSome(row) | 82 | ), |
| 83 | ? Option.some(decodeSourceAuthSession(row.value)) | 83 | ) |
| 84 | : Option.none<SourceAuthSession>(); | 84 | .orderBy( |
| 85 | }), | 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 | insert: (session: SourceAuthSession) => | 97 | insert: (session: SourceAuthSession) => |
| 88 | client.use("rows.source_auth_sessions.insert", async (db) => { | 98 | client.use("rows.source_auth_sessions.insert", async (db) => { |
| @@ -123,7 +133,7 @@ export const createSourceAuthSessionsRepo = ( · 34 unmodified lines | |||
| 123 | workspaceId: SourceAuthSession["workspaceId"], | 133 | workspaceId: SourceAuthSession["workspaceId"], |
| 124 | sourceId: SourceAuthSession["sourceId"], | 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 | const deleted = await db | 137 | const deleted = await db |
| 128 | .delete(tables.sourceAuthSessionsTable) | 138 | .delete(tables.sourceAuthSessionsTable) |
| 129 | .where( | 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 | } from "#schema"; | 4 | } from "#schema"; |
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | import { Schema } from "effect"; | 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 | import type { DrizzleClient } from "../client"; | 9 | import type { DrizzleClient } from "../client"; |
| 10 | import type { DrizzleTables } from "../schema"; | 10 | import type { DrizzleTables } from "../schema"; |
| @@ -18,12 +18,15 @@ const toSourceUpdateSet = ( · 7 unmodified lines | |||
| 18 | ): Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> => { | 18 | ): Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> => { |
| 19 | const set: Partial<DrizzleTables["sourcesTable"]["$inferInsert"]> = {}; | 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 | if (patch.name !== undefined) set.name = patch.name; | 23 | if (patch.name !== undefined) set.name = patch.name; |
| 22 | if (patch.kind !== undefined) set.kind = patch.kind; | 24 | if (patch.kind !== undefined) set.kind = patch.kind; |
| 23 | if (patch.endpoint !== undefined) set.endpoint = patch.endpoint; | 25 | if (patch.endpoint !== undefined) set.endpoint = patch.endpoint; |
| 24 | if (patch.status !== undefined) set.status = patch.status; | 26 | if (patch.status !== undefined) set.status = patch.status; |
| 25 | if (patch.enabled !== undefined) set.enabled = patch.enabled; | 27 | if (patch.enabled !== undefined) set.enabled = patch.enabled; |
| 26 | if (patch.namespace !== undefined) set.namespace = patch.namespace; | 28 | if (patch.namespace !== undefined) set.namespace = patch.namespace; |
| 29 | if (patch.bindingConfigJson !== undefined) set.bindingConfigJson = patch.bindingConfigJson; | ||
| 27 | if (patch.transport !== undefined) set.transport = patch.transport; | 30 | if (patch.transport !== undefined) set.transport = patch.transport; |
| 28 | if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson; | 31 | if (patch.queryParamsJson !== undefined) set.queryParamsJson = patch.queryParamsJson; |
| 29 | if (patch.headersJson !== undefined) set.headersJson = patch.headersJson; | 32 | if (patch.headersJson !== undefined) set.headersJson = patch.headersJson; |
| @@ -43,6 +46,20 @@ export const createSourcesRepo = ( · 13 unmodified lines | |||
| 43 | client: DrizzleClient, | 46 | client: DrizzleClient, |
| 44 | tables: DrizzleTables, | 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 | listByWorkspaceId: (workspaceId: StoredSourceRecord["workspaceId"]) => | 63 | listByWorkspaceId: (workspaceId: StoredSourceRecord["workspaceId"]) => |
| 47 | client.use("rows.sources.list_by_workspace", async (db) => { | 64 | client.use("rows.sources.list_by_workspace", async (db) => { |
| 48 | const rows = await db | 65 | const rows = await db |
| @@ -76,6 +93,26 @@ export const createSourcesRepo = ( · 27 unmodified lines | |||
| 76 | : Option.none<StoredSourceRecord>(); | 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 | insert: (source: StoredSourceRecord) => | 116 | insert: (source: StoredSourceRecord) => |
| 80 | client.use("rows.sources.insert", async (db) => { | 117 | client.use("rows.sources.insert", async (db) => { |
| 81 | await db.insert(tables.sourcesTable).values(encodeStoredSourceRecord(source)); | 118 | await db.insert(tables.sourcesTable).values(encodeStoredSourceRecord(source)); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { type Workspace, WorkspaceSchema } from "#schema"; | 1 | import { type Workspace, WorkspaceSchema } from "#schema"; |
| 2 | import * as Option from "effect/Option"; | 2 | import * as Option from "effect/Option"; |
| 3 | import { Schema } from "effect"; | 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 | import type { DrizzleClient } from "../client"; | 6 | import type { DrizzleClient } from "../client"; |
| 7 | import type { DrizzleTables } from "../schema"; | 7 | import type { DrizzleTables } from "../schema"; |
| @@ -69,7 +69,16 @@ export const createWorkspacesRepo = ( · 61 unmodified lines | |||
| 69 | .select({ id: tables.executionsTable.id }) | 69 | .select({ id: tables.executionsTable.id }) |
| 70 | .from(tables.executionsTable) | 70 | .from(tables.executionsTable) |
| 71 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); | 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 | const executionIds = executionRows.map((execution) => execution.id); | 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 | const credentials = await tx | 82 | const credentials = await tx |
| 74 | .select({ | 83 | .select({ |
| 75 | tokenProviderId: tables.credentialsTable.tokenProviderId, | 84 | tokenProviderId: tables.credentialsTable.tokenProviderId, |
| @@ -79,6 +88,26 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | |||
| 79 | }) | 88 | }) |
| 80 | .from(tables.credentialsTable) | 89 | .from(tables.credentialsTable) |
| 81 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | 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 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | 111 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); |
| 83 | 112 | ||
| 84 | if (executionIds.length > 0) { | 113 | if (executionIds.length > 0) { |
| @@ -87,6 +116,39 @@ export const createWorkspacesRepo = ( · 2 unmodified lines | |||
| 87 | .where(inArray(tables.executionInteractionsTable.executionId, executionIds)); | 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 | await tx | 152 | await tx |
| 91 | .delete(tables.executionsTable) | 153 | .delete(tables.executionsTable) |
| 92 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); | 154 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| @@ -96,17 +158,41 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | |||
| 96 | .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId)); | 158 | .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId)); |
| 97 | 159 | ||
| 98 | await tx | 160 | await tx |
| 99 | .delete(tables.sourceCredentialBindingsTable) | 161 | .delete(tables.workspaceSourceOauthClientsTable) |
| 100 | .where(eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId)); | 162 | .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId)); |
| 101 | 163 | ||
| 102 | await tx | 164 | await tx |
| 103 | .delete(tables.credentialsTable) | 165 | .delete(tables.credentialsTable) |
| 104 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | 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 | await tx | 172 | await tx |
| 107 | .delete(tables.sourcesTable) | 173 | .delete(tables.sourcesTable) |
| 108 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); | 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 | await tx | 196 | await tx |
| 111 | .delete(tables.policiesTable) | 197 | .delete(tables.policiesTable) |
| 112 | .where( | 198 | .where( |
| @@ -16,12 +16,16 @@ export const tableNames = { · 15 unmodified lines | |||
| 16 | organizationMemberships: "organization_memberships", | 16 | organizationMemberships: "organization_memberships", |
| 17 | workspaces: "workspaces", | 17 | workspaces: "workspaces", |
| 18 | sources: "sources", | 18 | sources: "sources", |
| 19 | sourceRecipes: "source_recipes", | ||
| 20 | sourceRecipeRevisions: "source_recipe_revisions", | ||
| 21 | sourceRecipeDocuments: "source_recipe_documents", | ||
| 22 | sourceRecipeOperations: "source_recipe_operations", | ||
| 19 | toolArtifacts: "tool_artifacts", | 23 | toolArtifacts: "tool_artifacts", |
| 20 | toolArtifactParameters: "tool_artifact_parameters", | 24 | toolArtifactParameters: "tool_artifact_parameters", |
| 21 | toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types", | 25 | toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types", |
| 22 | toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys", | 26 | toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys", |
| 23 | credentials: "credentials", | 27 | credentials: "workspace_source_credentials", |
| 24 | sourceCredentialBindings: "source_credential_bindings", | 28 | workspaceSourceOauthClients: "workspace_source_oauth_clients", |
| 25 | secretMaterials: "secret_materials", | 29 | secretMaterials: "secret_materials", |
| 26 | sourceAuthSessions: "source_auth_sessions", | 30 | sourceAuthSessions: "source_auth_sessions", |
| 27 | policies: "policies", | 31 | policies: "policies", |
| @@ -133,12 +137,15 @@ export const sourcesTable = pgTable( · 105 unmodified lines | |||
| 133 | { | 137 | { |
| 134 | workspaceId: text("workspace_id").notNull(), | 138 | workspaceId: text("workspace_id").notNull(), |
| 135 | sourceId: text("source_id").notNull(), | 139 | sourceId: text("source_id").notNull(), |
| 140 | recipeId: text("recipe_id").notNull(), | ||
| 141 | recipeRevisionId: text("recipe_revision_id").notNull(), | ||
| 136 | name: text("name").notNull(), | 142 | name: text("name").notNull(), |
| 137 | kind: text("kind").notNull(), | 143 | kind: text("kind").notNull(), |
| 138 | endpoint: text("endpoint").notNull(), | 144 | endpoint: text("endpoint").notNull(), |
| 139 | status: text("status").notNull(), | 145 | status: text("status").notNull(), |
| 140 | enabled: boolean("enabled").notNull(), | 146 | enabled: boolean("enabled").notNull(), |
| 141 | namespace: text("namespace"), | 147 | namespace: text("namespace"), |
| 148 | bindingConfigJson: text("binding_config_json"), | ||
| 142 | transport: text("transport"), | 149 | transport: text("transport"), |
| 143 | queryParamsJson: text("query_params_json"), | 150 | queryParamsJson: text("query_params_json"), |
| 144 | headersJson: text("headers_json"), | 151 | headersJson: text("headers_json"), |
| @@ -159,6 +166,12 @@ export const sourcesTable = pgTable( · 14 unmodified lines | |||
| 159 | table.updatedAt, | 166 | table.updatedAt, |
| 160 | table.sourceId, | 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 | uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name), | 175 | uniqueIndex("sources_workspace_name_idx").on(table.workspaceId, table.name), |
| 163 | check( | 176 | check( |
| 164 | "sources_kind_check", | 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 | export const credentialsTable = pgTable( | 365 | export const credentialsTable = pgTable( |
| 179 | tableNames.credentials, | 366 | tableNames.credentials, |
| 180 | { | 367 | { |
| 181 | id: text("id").notNull().primaryKey(), | 368 | id: text("id").notNull().primaryKey(), |
| 182 | workspaceId: text("workspace_id").notNull(), | 369 | workspaceId: text("workspace_id").notNull(), |
| 370 | sourceId: text("source_id").notNull(), | ||
| 371 | actorAccountId: text("actor_account_id"), | ||
| 183 | authKind: text("auth_kind").notNull(), | 372 | authKind: text("auth_kind").notNull(), |
| 184 | authHeaderName: text("auth_header_name").notNull(), | 373 | authHeaderName: text("auth_header_name").notNull(), |
| 185 | authPrefix: text("auth_prefix").notNull(), | 374 | authPrefix: text("auth_prefix").notNull(), |
| @@ -196,6 +385,17 @@ export const credentialsTable = pgTable( · 10 unmodified lines | |||
| 196 | table.updatedAt, | 385 | table.updatedAt, |
| 197 | table.id, | 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 | check( | 399 | check( |
| 200 | "credentials_auth_kind_check", | 400 | "credentials_auth_kind_check", |
| 201 | sql`${table.authKind} in ('bearer', 'oauth2')`, | 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( | 406 | export const workspaceSourceOauthClientsTable = pgTable( |
| 207 | tableNames.sourceCredentialBindings, | 407 | tableNames.workspaceSourceOauthClients, |
| 208 | { | 408 | { |
| 209 | id: text("id").notNull().primaryKey(), | 409 | id: text("id").notNull().primaryKey(), |
| 210 | workspaceId: text("workspace_id").notNull(), | 410 | workspaceId: text("workspace_id").notNull(), |
| 211 | sourceId: text("source_id").notNull(), | 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 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | 417 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 214 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | 418 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 215 | }, | 419 | }, |
| 216 | (table) => [ | 420 | (table) => [ |
| 217 | uniqueIndex("source_credential_bindings_workspace_source_idx").on( | 421 | uniqueIndex("workspace_source_oauth_clients_workspace_source_provider_idx").on( |
| 218 | table.workspaceId, | 422 | table.workspaceId, |
| 219 | table.sourceId, | 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 | table.workspaceId, | 427 | table.workspaceId, |
| 223 | table.updatedAt, | 428 | table.updatedAt, |
| 224 | table.sourceId, | 429 | table.sourceId, |
| @@ -379,21 +584,13 @@ export const sourceAuthSessionsTable = pgTable( · 154 unmodified lines | |||
| 379 | id: text("id").notNull().primaryKey(), | 584 | id: text("id").notNull().primaryKey(), |
| 380 | workspaceId: text("workspace_id").notNull(), | 585 | workspaceId: text("workspace_id").notNull(), |
| 381 | sourceId: text("source_id").notNull(), | 586 | sourceId: text("source_id").notNull(), |
| 587 | actorAccountId: text("actor_account_id"), | ||
| 382 | executionId: text("execution_id"), | 588 | executionId: text("execution_id"), |
| 383 | interactionId: text("interaction_id"), | 589 | interactionId: text("interaction_id"), |
| 384 | strategy: text("strategy").notNull(), | 590 | providerKind: text("provider_kind").notNull(), |
| 385 | status: text("status").notNull(), | 591 | status: text("status").notNull(), |
| 386 | endpoint: text("endpoint").notNull(), | ||
| 387 | state: text("state").notNull(), | 592 | state: text("state").notNull(), |
| 388 | redirectUri: text("redirect_uri").notNull(), | 593 | sessionDataJson: text("session_data_json").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"), | ||
| 397 | errorText: text("error_text"), | 594 | errorText: text("error_text"), |
| 398 | completedAt: bigint("completed_at", { mode: "number" }), | 595 | completedAt: bigint("completed_at", { mode: "number" }), |
| 399 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | 596 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| @@ -408,14 +605,15 @@ export const sourceAuthSessionsTable = pgTable( · 8 unmodified lines | |||
| 408 | index("source_auth_sessions_pending_idx").on( | 605 | index("source_auth_sessions_pending_idx").on( |
| 409 | table.workspaceId, | 606 | table.workspaceId, |
| 410 | table.sourceId, | 607 | table.sourceId, |
| 608 | table.actorAccountId, | ||
| 411 | table.status, | 609 | table.status, |
| 412 | table.updatedAt, | 610 | table.updatedAt, |
| 413 | table.id, | 611 | table.id, |
| 414 | ), | 612 | ), |
| 415 | uniqueIndex("source_auth_sessions_state_idx").on(table.state), | 613 | uniqueIndex("source_auth_sessions_state_idx").on(table.state), |
| 416 | check( | 614 | check( |
| 417 | "source_auth_sessions_strategy_check", | 615 | "source_auth_sessions_provider_kind_check", |
| 418 | sql`${table.strategy} in ('oauth2_authorization_code')`, | 616 | sql`${table.providerKind} in ('mcp_oauth', 'oauth2_pkce')`, |
| 419 | ), | 617 | ), |
| 420 | check( | 618 | check( |
| 421 | "source_auth_sessions_status_check", | 619 | "source_auth_sessions_status_check", |
| @@ -561,12 +759,16 @@ export const drizzleSchema = { · 139 unmodified lines | |||
| 561 | organizationMembershipsTable, | 759 | organizationMembershipsTable, |
| 562 | workspacesTable, | 760 | workspacesTable, |
| 563 | sourcesTable, | 761 | sourcesTable, |
| 762 | sourceRecipesTable, | ||
| 763 | sourceRecipeRevisionsTable, | ||
| 764 | sourceRecipeDocumentsTable, | ||
| 765 | sourceRecipeOperationsTable, | ||
| 564 | credentialsTable, | 766 | credentialsTable, |
| 767 | workspaceSourceOauthClientsTable, | ||
| 565 | toolArtifactsTable, | 768 | toolArtifactsTable, |
| 566 | toolArtifactParametersTable, | 769 | toolArtifactParametersTable, |
| 567 | toolArtifactRequestBodyContentTypesTable, | 770 | toolArtifactRequestBodyContentTypesTable, |
| 568 | toolArtifactRefHintKeysTable, | 771 | toolArtifactRefHintKeysTable, |
| 569 | sourceCredentialBindingsTable, | ||
| 570 | secretMaterialsTable, | 772 | secretMaterialsTable, |
| 571 | sourceAuthSessionsTable, | 773 | sourceAuthSessionsTable, |
| 572 | policiesTable, | 774 | policiesTable, |
| @@ -29,55 +29,51 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 28 unmodified lines | |||
| 29 | }); | 29 | }); |
| 30 | 30 | ||
| 31 | describe("source-auth-service", () => { | 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 | it("clears ephemeral OAuth session fields when failing a session", () => { | 46 | it("clears ephemeral OAuth session fields when failing a session", () => { |
| 33 | const patch = createTerminalSourceAuthSessionPatch({ | 47 | const patch = createTerminalSourceAuthSessionPatch({ |
| 48 | sessionDataJson: baseSessionDataJson, | ||
| 34 | status: "failed", | 49 | status: "failed", |
| 35 | now: 123, | 50 | now: 123, |
| 36 | errorText: "OAuth authorization failed", | 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 | expect(patch).toMatchObject({ | 54 | expect(patch).toMatchObject({ |
| 55 | sessionDataJson: baseSessionDataJson, | ||
| 44 | status: "failed", | 56 | status: "failed", |
| 45 | errorText: "OAuth authorization failed", | 57 | errorText: "OAuth authorization failed", |
| 46 | completedAt: 123, | 58 | completedAt: 123, |
| 47 | updatedAt: 123, | 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 | it("clears ephemeral OAuth session fields when completing a session", () => { | 63 | it("clears ephemeral OAuth session fields when completing a session", () => { |
| 59 | const patch = createTerminalSourceAuthSessionPatch({ | 64 | const patch = createTerminalSourceAuthSessionPatch({ |
| 65 | sessionDataJson: baseSessionDataJson, | ||
| 60 | status: "completed", | 66 | status: "completed", |
| 61 | now: 456, | 67 | now: 456, |
| 62 | errorText: null, | 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 | expect(patch).toMatchObject({ | 71 | expect(patch).toMatchObject({ |
| 72 | sessionDataJson: baseSessionDataJson, | ||
| 70 | status: "completed", | 73 | status: "completed", |
| 71 | errorText: null, | 74 | errorText: null, |
| 72 | completedAt: 456, | 75 | completedAt: 456, |
| 73 | updatedAt: 456, | 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 | type SqlControlPlaneRows, | 8 | type SqlControlPlaneRows, |
| 9 | } from "#persistence"; | 9 | } from "#persistence"; |
| 10 | import { | 10 | import { |
| 11 | AccountId, | ||
| 11 | ExecutionIdSchema, | 12 | ExecutionIdSchema, |
| 12 | type SecretMaterialPurpose, | 13 | type SecretMaterialPurpose, |
| 13 | Source, | 14 | Source, |
| @@ -163,27 +164,74 @@ const probeMcpSourceWithoutAuth = ( · 149 unmodified lines | |||
| 163 | }); | 164 | }); |
| 164 | 165 | ||
| 165 | export const createTerminalSourceAuthSessionPatch = (input: { | 166 | export const createTerminalSourceAuthSessionPatch = (input: { |
| 167 | sessionDataJson: string; | ||
| 166 | status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">; | 168 | status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">; |
| 167 | now: number; | 169 | now: number; |
| 168 | errorText: string | null; | 170 | errorText: string | null; |
| 169 | resourceMetadataUrl?: string | null; | ||
| 170 | authorizationServerUrl?: string | null; | ||
| 171 | resourceMetadataJson?: string | null; | ||
| 172 | authorizationServerMetadataJson?: string | null; | ||
| 173 | }) => ({ | 171 | }) => ({ |
| 174 | status: input.status, | 172 | status: input.status, |
| 175 | errorText: input.errorText, | 173 | errorText: input.errorText, |
| 176 | completedAt: input.now, | 174 | completedAt: input.now, |
| 177 | updatedAt: input.now, | 175 | updatedAt: input.now, |
| 178 | codeVerifier: null, | 176 | sessionDataJson: input.sessionDataJson, |
| 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, | ||
| 185 | }) satisfies Partial<SourceAuthSession>; | 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 | const completeLiveInteraction = (input: { | 236 | const completeLiveInteraction = (input: { |
| 189 | liveExecutionManager: LiveExecutionManager; | 237 | liveExecutionManager: LiveExecutionManager; |
| @@ -217,6 +265,7 @@ const completeLiveInteraction = (input: { · 27 unmodified lines | |||
| 217 | }); | 265 | }); |
| 218 | 266 | ||
| 219 | const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { | 267 | const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { |
| 268 | actorAccountId?: AccountId | null; | ||
| 220 | status: Source["status"]; | 269 | status: Source["status"]; |
| 221 | lastError?: string | null; | 270 | lastError?: string | null; |
| 222 | auth?: Source["auth"]; | 271 | auth?: Source["auth"]; |
| @@ -225,6 +274,7 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines | |||
| 225 | const latest = yield* loadSourceById(rows, { | 274 | const latest = yield* loadSourceById(rows, { |
| 226 | workspaceId: source.workspaceId, | 275 | workspaceId: source.workspaceId, |
| 227 | sourceId: source.id, | 276 | sourceId: source.id, |
| 277 | actorAccountId: input.actorAccountId, | ||
| 228 | }); | 278 | }); |
| 229 | 279 | ||
| 230 | return yield* persistSource(rows, { | 280 | return yield* persistSource(rows, { |
| @@ -233,6 +283,8 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 2 unmodified lines | |||
| 233 | lastError: input.lastError ?? null, | 283 | lastError: input.lastError ?? null, |
| 234 | auth: input.auth ?? latest.auth, | 284 | auth: input.auth ?? latest.auth, |
| 235 | updatedAt: Date.now(), | 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 | kind?: "mcp"; | 330 | kind?: "mcp"; |
| 279 | workspaceId: WorkspaceId; | 331 | workspaceId: WorkspaceId; |
| 332 | actorAccountId?: AccountId | null; | ||
| 280 | executionId: SourceAuthSession["executionId"]; | 333 | executionId: SourceAuthSession["executionId"]; |
| 281 | interactionId: SourceAuthSession["interactionId"]; | 334 | interactionId: SourceAuthSession["interactionId"]; |
| 282 | endpoint: string; | 335 | endpoint: string; |
| @@ -286,6 +339,7 @@ export type ExecutorAddSourceInput = · 3 unmodified lines | |||
| 286 | | { | 339 | | { |
| 287 | kind: "openapi"; | 340 | kind: "openapi"; |
| 288 | workspaceId: WorkspaceId; | 341 | workspaceId: WorkspaceId; |
| 342 | actorAccountId?: AccountId | null; | ||
| 289 | executionId: SourceAuthSession["executionId"]; | 343 | executionId: SourceAuthSession["executionId"]; |
| 290 | interactionId: SourceAuthSession["interactionId"]; | 344 | interactionId: SourceAuthSession["interactionId"]; |
| 291 | endpoint: string; | 345 | endpoint: string; |
| @@ -297,6 +351,7 @@ export type ExecutorAddSourceInput = · 5 unmodified lines | |||
| 297 | | { | 351 | | { |
| 298 | kind: "graphql"; | 352 | kind: "graphql"; |
| 299 | workspaceId: WorkspaceId; | 353 | workspaceId: WorkspaceId; |
| 354 | actorAccountId?: AccountId | null; | ||
| 300 | executionId: SourceAuthSession["executionId"]; | 355 | executionId: SourceAuthSession["executionId"]; |
| 301 | interactionId: SourceAuthSession["interactionId"]; | 356 | interactionId: SourceAuthSession["interactionId"]; |
| 302 | endpoint: string; | 357 | endpoint: string; |
| @@ -307,6 +362,7 @@ export type ExecutorAddSourceInput = · 4 unmodified lines | |||
| 307 | 362 | ||
| 308 | export type ConnectMcpSourceInput = { | 363 | export type ConnectMcpSourceInput = { |
| 309 | workspaceId: WorkspaceId; | 364 | workspaceId: WorkspaceId; |
| 365 | actorAccountId?: AccountId | null; | ||
| 310 | sourceId?: Source["id"] | null; | 366 | sourceId?: Source["id"] | null; |
| 311 | endpoint: string; | 367 | endpoint: string; |
| 312 | name?: string | null; | 368 | name?: string | null; |
| @@ -332,6 +388,7 @@ export type SourceOAuthProviderInput = { · 19 unmodified lines | |||
| 332 | 388 | ||
| 333 | export type StartSourceOAuthSessionInput = { | 389 | export type StartSourceOAuthSessionInput = { |
| 334 | workspaceId: WorkspaceId; | 390 | workspaceId: WorkspaceId; |
| 391 | actorAccountId?: AccountId | null; | ||
| 335 | provider: SourceOAuthProviderInput; | 392 | provider: SourceOAuthProviderInput; |
| 336 | baseUrl?: string | null; | 393 | baseUrl?: string | null; |
| 337 | displayName?: string | null; | 394 | displayName?: string | null; |
| @@ -482,6 +539,7 @@ const connectMcpSourceInternal = (input: { · 144 unmodified lines | |||
| 482 | getLocalServerBaseUrl?: () => string | undefined; | 539 | getLocalServerBaseUrl?: () => string | undefined; |
| 483 | baseUrl?: string | null; | 540 | baseUrl?: string | null; |
| 484 | workspaceId: WorkspaceId; | 541 | workspaceId: WorkspaceId; |
| 542 | actorAccountId?: AccountId | null; | ||
| 485 | sourceId?: Source["id"] | null; | 543 | sourceId?: Source["id"] | null; |
| 486 | executionId?: SourceAuthSession["executionId"]; | 544 | executionId?: SourceAuthSession["executionId"]; |
| 487 | interactionId?: SourceAuthSession["interactionId"]; | 545 | interactionId?: SourceAuthSession["interactionId"]; |
| @@ -502,6 +560,7 @@ const connectMcpSourceInternal = (input: { · 14 unmodified lines | |||
| 502 | ? loadSourceById(input.rows, { | 560 | ? loadSourceById(input.rows, { |
| 503 | workspaceId: input.workspaceId, | 561 | workspaceId: input.workspaceId, |
| 504 | sourceId: input.sourceId, | 562 | sourceId: input.sourceId, |
| 563 | actorAccountId: input.actorAccountId, | ||
| 505 | }).pipe( | 564 | }).pipe( |
| 506 | Effect.flatMap((source) => | 565 | Effect.flatMap((source) => |
| 507 | source.kind === "mcp" | 566 | source.kind === "mcp" |
| @@ -509,7 +568,9 @@ const connectMcpSourceInternal = (input: { · 1 unmodified line | |||
| 509 | : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)), | 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 | Effect.map((sources) => | 574 | Effect.map((sources) => |
| 514 | sources.find( | 575 | sources.find( |
| 515 | (source) => | 576 | (source) => |
| @@ -569,7 +630,9 @@ const connectMcpSourceInternal = (input: { · 53 unmodified lines | |||
| 569 | now, | 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 | yield* syncSourceToolArtifacts({ | 636 | yield* syncSourceToolArtifacts({ |
| 574 | rows: input.rows, | 637 | rows: input.rows, |
| 575 | source: persistedDraft, | 638 | source: persistedDraft, |
| @@ -588,6 +651,7 @@ const connectMcpSourceInternal = (input: { · 12 unmodified lines | |||
| 588 | onRight: (result) => | 651 | onRight: (result) => |
| 589 | Effect.gen(function* () { | 652 | Effect.gen(function* () { |
| 590 | const connected = yield* updateSourceStatus(input.rows, persistedDraft, { | 653 | const connected = yield* updateSourceStatus(input.rows, persistedDraft, { |
| 654 | actorAccountId: input.actorAccountId, | ||
| 591 | status: "connected", | 655 | status: "connected", |
| 592 | lastError: null, | 656 | lastError: null, |
| 593 | auth: { kind: "none" }, | 657 | auth: { kind: "none" }, |
| @@ -603,6 +667,7 @@ const connectMcpSourceInternal = (input: { · 9 unmodified lines | |||
| 603 | return yield* Either.match(indexed, { | 667 | return yield* Either.match(indexed, { |
| 604 | onLeft: (error) => | 668 | onLeft: (error) => |
| 605 | updateSourceStatus(input.rows, connected, { | 669 | updateSourceStatus(input.rows, connected, { |
| 670 | actorAccountId: input.actorAccountId, | ||
| 606 | status: "error", | 671 | status: "error", |
| 607 | lastError: error.message, | 672 | lastError: error.message, |
| 608 | }).pipe( | 673 | }).pipe( |
| @@ -642,6 +707,7 @@ const connectMcpSourceInternal = (input: { · 33 unmodified lines | |||
| 642 | }); | 707 | }); |
| 643 | 708 | ||
| 644 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { | 709 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { |
| 710 | actorAccountId: input.actorAccountId, | ||
| 645 | status: "auth_required", | 711 | status: "auth_required", |
| 646 | lastError: null, | 712 | lastError: null, |
| 647 | }); | 713 | }); |
| @@ -651,21 +717,25 @@ const connectMcpSourceInternal = (input: { · 3 unmodified lines | |||
| 651 | id: sessionId, | 717 | id: sessionId, |
| 652 | workspaceId: input.workspaceId, | 718 | workspaceId: input.workspaceId, |
| 653 | sourceId: authRequiredSource.id, | 719 | sourceId: authRequiredSource.id, |
| 720 | actorAccountId: input.actorAccountId ?? null, | ||
| 654 | executionId: input.executionId ?? null, | 721 | executionId: input.executionId ?? null, |
| 655 | interactionId: input.interactionId ?? null, | 722 | interactionId: input.interactionId ?? null, |
| 656 | strategy: "oauth2_authorization_code", | 723 | providerKind: "mcp_oauth", |
| 657 | status: "pending", | 724 | status: "pending", |
| 658 | endpoint: normalizedEndpoint, | ||
| 659 | state, | 725 | state, |
| 660 | redirectUri: redirectUrl, | 726 | sessionDataJson: encodeMcpSourceAuthSessionData({ |
| 661 | scope: null, | 727 | kind: "mcp_oauth", |
| 662 | resourceMetadataUrl: oauthStart.resourceMetadataUrl, | 728 | endpoint: normalizedEndpoint, |
| 663 | authorizationServerUrl: oauthStart.authorizationServerUrl, | 729 | redirectUri: redirectUrl, |
| 664 | resourceMetadataJson: oauthStart.resourceMetadataJson, | 730 | scope: null, |
| 665 | authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, | 731 | resourceMetadataUrl: oauthStart.resourceMetadataUrl, |
| 666 | clientInformationJson: oauthStart.clientInformationJson, | 732 | authorizationServerUrl: oauthStart.authorizationServerUrl, |
| 667 | codeVerifier: oauthStart.codeVerifier, | 733 | resourceMetadataJson: oauthStart.resourceMetadataJson, |
| 668 | authorizationUrl: oauthStart.authorizationUrl, | 734 | authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, |
| 735 | clientInformationJson: oauthStart.clientInformationJson, | ||
| 736 | codeVerifier: oauthStart.codeVerifier, | ||
| 737 | authorizationUrl: oauthStart.authorizationUrl, | ||
| 738 | }), | ||
| 669 | errorText: null, | 739 | errorText: null, |
| 670 | completedAt: null, | 740 | completedAt: null, |
| 671 | createdAt: sessionNow, | 741 | createdAt: sessionNow, |
| @@ -694,6 +764,9 @@ const addExecutorHttpSource = (input: { · 22 unmodified lines | |||
| 694 | const existingSources = yield* loadSourcesInWorkspace( | 764 | const existingSources = yield* loadSourcesInWorkspace( |
| 695 | input.rows, | 765 | input.rows, |
| 696 | input.sourceInput.workspaceId, | 766 | input.sourceInput.workspaceId, |
| 767 | { | ||
| 768 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 769 | }, | ||
| 697 | ); | 770 | ); |
| 698 | const existing = existingSources.find((source) => { | 771 | const existing = existingSources.find((source) => { |
| 699 | if (source.kind !== input.sourceInput.kind) { | 772 | if (source.kind !== input.sourceInput.kind) { |
| @@ -757,7 +830,9 @@ const addExecutorHttpSource = (input: { · 57 unmodified lines | |||
| 757 | now, | 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 | return { | 836 | return { |
| 762 | kind: "credential_required", | 837 | kind: "credential_required", |
| 763 | source: persistedDraft, | 838 | source: persistedDraft, |
| @@ -802,7 +877,9 @@ const addExecutorHttpSource = (input: { · 38 unmodified lines | |||
| 802 | now, | 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 | const synced = yield* Effect.either( | 883 | const synced = yield* Effect.either( |
| 807 | syncSourceToolArtifacts({ | 884 | syncSourceToolArtifacts({ |
| 808 | rows: input.rows, | 885 | rows: input.rows, |
| @@ -817,6 +894,7 @@ const addExecutorHttpSource = (input: { · 8 unmodified lines | |||
| 817 | return yield* Either.match(synced, { | 894 | return yield* Either.match(synced, { |
| 818 | onLeft: (error) => | 895 | onLeft: (error) => |
| 819 | updateSourceStatus(input.rows, persistedDraft, { | 896 | updateSourceStatus(input.rows, persistedDraft, { |
| 897 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 820 | status: "error", | 898 | status: "error", |
| 821 | lastError: error.message, | 899 | lastError: error.message, |
| 822 | }).pipe( | 900 | }).pipe( |
| @@ -824,6 +902,7 @@ const addExecutorHttpSource = (input: { · 1 unmodified line | |||
| 824 | ), | 902 | ), |
| 825 | onRight: () => | 903 | onRight: () => |
| 826 | updateSourceStatus(input.rows, persistedDraft, { | 904 | updateSourceStatus(input.rows, persistedDraft, { |
| 905 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 827 | status: "connected", | 906 | status: "connected", |
| 828 | lastError: null, | 907 | lastError: null, |
| 829 | }).pipe( | 908 | }).pipe( |
| @@ -841,6 +920,7 @@ type RuntimeSourceAuthServiceShape = { · 11 unmodified lines | |||
| 841 | getSourceById: (input: { | 920 | getSourceById: (input: { |
| 842 | workspaceId: WorkspaceId; | 921 | workspaceId: WorkspaceId; |
| 843 | sourceId: Source["id"]; | 922 | sourceId: Source["id"]; |
| 923 | actorAccountId?: AccountId | null; | ||
| 844 | }) => Effect.Effect<Source, Error, never>; | 924 | }) => Effect.Effect<Source, Error, never>; |
| 845 | getLocalServerBaseUrl: () => string | null; | 925 | getLocalServerBaseUrl: () => string | null; |
| 846 | storeSecretMaterial: (input: { | 926 | storeSecretMaterial: (input: { |
| @@ -869,6 +949,7 @@ type RuntimeSourceAuthServiceShape = { · 22 unmodified lines | |||
| 869 | completeSourceCredentialSetup: (input: { | 949 | completeSourceCredentialSetup: (input: { |
| 870 | workspaceId: WorkspaceId; | 950 | workspaceId: WorkspaceId; |
| 871 | sourceId: Source["id"]; | 951 | sourceId: Source["id"]; |
| 952 | actorAccountId?: AccountId | null; | ||
| 872 | state: string; | 953 | state: string; |
| 873 | code?: string | null; | 954 | code?: string | null; |
| 874 | error?: string | null; | 955 | error?: string | null; |
| @@ -897,10 +978,11 @@ export const createRuntimeSourceAuthService = (input: { · 22 unmodified lines | |||
| 897 | value, | 978 | value, |
| 898 | }), | 979 | }), |
| 899 | 980 | ||
| 900 | getSourceById: ({ workspaceId, sourceId }) => | 981 | getSourceById: ({ workspaceId, sourceId, actorAccountId }) => |
| 901 | loadSourceById(input.rows, { | 982 | loadSourceById(input.rows, { |
| 902 | workspaceId, | 983 | workspaceId, |
| 903 | sourceId, | 984 | sourceId, |
| 985 | actorAccountId, | ||
| 904 | }), | 986 | }), |
| 905 | 987 | ||
| 906 | addExecutorSource: (sourceInput, options) => | 988 | addExecutorSource: (sourceInput, options) => |
| @@ -915,6 +997,7 @@ export const createRuntimeSourceAuthService = (input: { · 8 unmodified lines | |||
| 915 | rows: input.rows, | 997 | rows: input.rows, |
| 916 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | 998 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 917 | workspaceId: sourceInput.workspaceId, | 999 | workspaceId: sourceInput.workspaceId, |
| 1000 | actorAccountId: sourceInput.actorAccountId, | ||
| 918 | executionId: sourceInput.executionId, | 1001 | executionId: sourceInput.executionId, |
| 919 | interactionId: sourceInput.interactionId, | 1002 | interactionId: sourceInput.interactionId, |
| 920 | endpoint: sourceInput.endpoint, | 1003 | endpoint: sourceInput.endpoint, |
| @@ -930,6 +1013,7 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines | |||
| 930 | rows: input.rows, | 1013 | rows: input.rows, |
| 931 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | 1014 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, |
| 932 | workspaceId: sourceInput.workspaceId, | 1015 | workspaceId: sourceInput.workspaceId, |
| 1016 | actorAccountId: sourceInput.actorAccountId, | ||
| 933 | sourceId: sourceInput.sourceId, | 1017 | sourceId: sourceInput.sourceId, |
| 934 | executionId: null, | 1018 | executionId: null, |
| 935 | interactionId: null, | 1019 | interactionId: null, |
| @@ -972,21 +1056,25 @@ export const createRuntimeSourceAuthService = (input: { · 36 unmodified lines | |||
| 972 | id: sessionId, | 1056 | id: sessionId, |
| 973 | workspaceId: oauthInput.workspaceId, | 1057 | workspaceId: oauthInput.workspaceId, |
| 974 | sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`), | 1058 | sourceId: SourceIdSchema.make(`oauth_draft_${crypto.randomUUID()}`), |
| 1059 | actorAccountId: oauthInput.actorAccountId ?? null, | ||
| 975 | executionId: null, | 1060 | executionId: null, |
| 976 | interactionId: null, | 1061 | interactionId: null, |
| 977 | strategy: "oauth2_authorization_code", | 1062 | providerKind: "mcp_oauth", |
| 978 | status: "pending", | 1063 | status: "pending", |
| 979 | endpoint, | ||
| 980 | state, | 1064 | state, |
| 981 | redirectUri: redirectUrl, | 1065 | sessionDataJson: encodeMcpSourceAuthSessionData({ |
| 982 | scope: oauthInput.provider.kind, | 1066 | kind: "mcp_oauth", |
| 983 | resourceMetadataUrl: oauthStart.resourceMetadataUrl, | 1067 | endpoint, |
| 984 | authorizationServerUrl: oauthStart.authorizationServerUrl, | 1068 | redirectUri: redirectUrl, |
| 985 | resourceMetadataJson: oauthStart.resourceMetadataJson, | 1069 | scope: oauthInput.provider.kind, |
| 986 | authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, | 1070 | resourceMetadataUrl: oauthStart.resourceMetadataUrl, |
| 987 | clientInformationJson: oauthStart.clientInformationJson, | 1071 | authorizationServerUrl: oauthStart.authorizationServerUrl, |
| 988 | codeVerifier: oauthStart.codeVerifier, | 1072 | resourceMetadataJson: oauthStart.resourceMetadataJson, |
| 989 | authorizationUrl: oauthStart.authorizationUrl, | 1073 | authorizationServerMetadataJson: oauthStart.authorizationServerMetadataJson, |
| 1074 | clientInformationJson: oauthStart.clientInformationJson, | ||
| 1075 | codeVerifier: oauthStart.codeVerifier, | ||
| 1076 | authorizationUrl: oauthStart.authorizationUrl, | ||
| 1077 | }), | ||
| 990 | errorText: null, | 1078 | errorText: null, |
| 991 | completedAt: null, | 1079 | completedAt: null, |
| 992 | createdAt: now, | 1080 | createdAt: now, |
| @@ -1012,6 +1100,7 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines | |||
| 1012 | } | 1100 | } |
| 1013 | 1101 | ||
| 1014 | const session = sessionOption.value; | 1102 | const session = sessionOption.value; |
| 1103 | const sessionData = decodeMcpSourceAuthSessionData(session); | ||
| 1015 | if (session.status === "completed") { | 1104 | if (session.status === "completed") { |
| 1016 | return yield* Effect.fail(new Error(`Source auth session ${session.id} is already completed`)); | 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 | yield* input.rows.sourceAuthSessions.update( | 1116 | yield* input.rows.sourceAuthSessions.update( |
| 1028 | session.id, | 1117 | session.id, |
| 1029 | createTerminalSourceAuthSessionPatch({ | 1118 | createTerminalSourceAuthSessionPatch({ |
| 1119 | sessionDataJson: session.sessionDataJson, | ||
| 1030 | status: "failed", | 1120 | status: "failed", |
| 1031 | now: failedAt, | 1121 | now: failedAt, |
| 1032 | errorText: reason, | 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 | return yield* Effect.fail(new Error("Missing OAuth authorization code")); | 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 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); | 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") { | 1138 | if (sessionData.scope !== null && sessionData.scope !== "mcp") { |
| 1053 | return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${session.scope}`)); | 1139 | return yield* Effect.fail(new Error(`Unsupported OAuth provider: ${sessionData.scope}`)); |
| 1054 | } | 1140 | } |
| 1055 | 1141 | ||
| 1056 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ | 1142 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ |
| 1057 | session: { | 1143 | session: { |
| 1058 | endpoint: session.endpoint, | 1144 | endpoint: sessionData.endpoint, |
| 1059 | redirectUrl: session.redirectUri, | 1145 | redirectUrl: sessionData.redirectUri, |
| 1060 | codeVerifier: session.codeVerifier, | 1146 | codeVerifier: sessionData.codeVerifier, |
| 1061 | resourceMetadataUrl: session.resourceMetadataUrl, | 1147 | resourceMetadataUrl: sessionData.resourceMetadataUrl, |
| 1062 | authorizationServerUrl: session.authorizationServerUrl, | 1148 | authorizationServerUrl: sessionData.authorizationServerUrl, |
| 1063 | resourceMetadataJson: session.resourceMetadataJson, | 1149 | resourceMetadataJson: sessionData.resourceMetadataJson, |
| 1064 | authorizationServerMetadataJson: session.authorizationServerMetadataJson, | 1150 | authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson, |
| 1065 | clientInformationJson: session.clientInformationJson, | 1151 | clientInformationJson: sessionData.clientInformationJson, |
| 1066 | }, | 1152 | }, |
| 1067 | code: authorizationCode, | 1153 | code: authorizationCode, |
| 1068 | }); | 1154 | }); |
| 1069 | 1155 | ||
| 1070 | const oauthSecretName = resolveSourceOAuthSecretName({ | 1156 | const oauthSecretName = resolveSourceOAuthSecretName({ |
| 1071 | displayName: readSourceOAuthSessionDisplayName(session.state), | 1157 | displayName: readSourceOAuthSessionDisplayName(session.state), |
| 1072 | endpoint: session.endpoint, | 1158 | endpoint: sessionData.endpoint, |
| 1073 | }); | 1159 | }); |
| 1074 | const accessTokenRef = yield* storeSecretMaterial({ | 1160 | const accessTokenRef = yield* storeSecretMaterial({ |
| 1075 | purpose: "oauth_access_token", | 1161 | purpose: "oauth_access_token", |
| @@ -1095,13 +1181,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines | |||
| 1095 | yield* input.rows.sourceAuthSessions.update( | 1181 | yield* input.rows.sourceAuthSessions.update( |
| 1096 | session.id, | 1182 | session.id, |
| 1097 | createTerminalSourceAuthSessionPatch({ | 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 | status: "completed", | 1195 | status: "completed", |
| 1099 | now: Date.now(), | 1196 | now: Date.now(), |
| 1100 | errorText: null, | 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 | completeSourceCredentialSetup: ({ | 1207 | completeSourceCredentialSetup: ({ |
| 1115 | workspaceId, | 1208 | workspaceId, |
| 1116 | sourceId, | 1209 | sourceId, |
| 1210 | actorAccountId, | ||
| 1117 | state, | 1211 | state, |
| 1118 | code, | 1212 | code, |
| 1119 | error, | 1213 | error, |
| @@ -1126,6 +1220,7 @@ export const createRuntimeSourceAuthService = (input: { · 6 unmodified lines | |||
| 1126 | } | 1220 | } |
| 1127 | 1221 | ||
| 1128 | const session = sessionOption.value; | 1222 | const session = sessionOption.value; |
| 1223 | const sessionData = decodeMcpSourceAuthSessionData(session); | ||
| 1129 | if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { | 1224 | if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { |
| 1130 | return yield* Effect.fail( | 1225 | return yield* Effect.fail( |
| 1131 | new Error( | 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 | const source = yield* loadSourceById(input.rows, { | 1240 | const source = yield* loadSourceById(input.rows, { |
| 1138 | workspaceId: session.workspaceId, | 1241 | workspaceId: session.workspaceId, |
| 1139 | sourceId: session.sourceId, | 1242 | sourceId: session.sourceId, |
| 1243 | actorAccountId: session.actorAccountId, | ||
| 1140 | }); | 1244 | }); |
| 1141 | 1245 | ||
| 1142 | if (session.status === "completed") { | 1246 | if (session.status === "completed") { |
| @@ -1156,16 +1260,14 @@ export const createRuntimeSourceAuthService = (input: { · 13 unmodified lines | |||
| 1156 | yield* input.rows.sourceAuthSessions.update( | 1260 | yield* input.rows.sourceAuthSessions.update( |
| 1157 | session.id, | 1261 | session.id, |
| 1158 | createTerminalSourceAuthSessionPatch({ | 1262 | createTerminalSourceAuthSessionPatch({ |
| 1263 | sessionDataJson: session.sessionDataJson, | ||
| 1159 | status: "failed", | 1264 | status: "failed", |
| 1160 | now: failedAt, | 1265 | now: failedAt, |
| 1161 | errorText: reason, | 1266 | errorText: reason, |
| 1162 | resourceMetadataUrl: session.resourceMetadataUrl, | ||
| 1163 | authorizationServerUrl: session.authorizationServerUrl, | ||
| 1164 | resourceMetadataJson: session.resourceMetadataJson, | ||
| 1165 | authorizationServerMetadataJson: session.authorizationServerMetadataJson, | ||
| 1166 | }), | 1267 | }), |
| 1167 | ); | 1268 | ); |
| 1168 | const failedSource = yield* updateSourceStatus(input.rows, source, { | 1269 | const failedSource = yield* updateSourceStatus(input.rows, source, { |
| 1270 | actorAccountId: session.actorAccountId, | ||
| 1169 | status: "error", | 1271 | status: "error", |
| 1170 | lastError: reason, | 1272 | lastError: reason, |
| 1171 | }); | 1273 | }); |
| @@ -1191,20 +1293,20 @@ export const createRuntimeSourceAuthService = (input: { · 19 unmodified lines | |||
| 1191 | return yield* Effect.fail(new Error("Missing OAuth authorization code")); | 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 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); | 1297 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); |
| 1196 | } | 1298 | } |
| 1197 | 1299 | ||
| 1198 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ | 1300 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ |
| 1199 | session: { | 1301 | session: { |
| 1200 | endpoint: session.endpoint, | 1302 | endpoint: sessionData.endpoint, |
| 1201 | redirectUrl: session.redirectUri, | 1303 | redirectUrl: sessionData.redirectUri, |
| 1202 | codeVerifier: session.codeVerifier, | 1304 | codeVerifier: sessionData.codeVerifier, |
| 1203 | resourceMetadataUrl: session.resourceMetadataUrl, | 1305 | resourceMetadataUrl: sessionData.resourceMetadataUrl, |
| 1204 | authorizationServerUrl: session.authorizationServerUrl, | 1306 | authorizationServerUrl: sessionData.authorizationServerUrl, |
| 1205 | resourceMetadataJson: session.resourceMetadataJson, | 1307 | resourceMetadataJson: sessionData.resourceMetadataJson, |
| 1206 | authorizationServerMetadataJson: session.authorizationServerMetadataJson, | 1308 | authorizationServerMetadataJson: sessionData.authorizationServerMetadataJson, |
| 1207 | clientInformationJson: session.clientInformationJson, | 1309 | clientInformationJson: sessionData.clientInformationJson, |
| 1208 | }, | 1310 | }, |
| 1209 | code: authorizationCode, | 1311 | code: authorizationCode, |
| 1210 | }); | 1312 | }); |
| @@ -1228,6 +1330,7 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines | |||
| 1228 | 1330 | ||
| 1229 | const now = Date.now(); | 1331 | const now = Date.now(); |
| 1230 | const connectedSource = yield* updateSourceStatus(input.rows, source, { | 1332 | const connectedSource = yield* updateSourceStatus(input.rows, source, { |
| 1333 | actorAccountId: session.actorAccountId, | ||
| 1231 | status: "connected", | 1334 | status: "connected", |
| 1232 | lastError: null, | 1335 | lastError: null, |
| 1233 | auth: { | 1336 | auth: { |
| @@ -1248,6 +1351,7 @@ export const createRuntimeSourceAuthService = (input: { · 14 unmodified lines | |||
| 1248 | yield* Either.match(indexed, { | 1351 | yield* Either.match(indexed, { |
| 1249 | onLeft: (error) => | 1352 | onLeft: (error) => |
| 1250 | updateSourceStatus(input.rows, connectedSource, { | 1353 | updateSourceStatus(input.rows, connectedSource, { |
| 1354 | actorAccountId: session.actorAccountId, | ||
| 1251 | status: "error", | 1355 | status: "error", |
| 1252 | lastError: error.message, | 1356 | lastError: error.message, |
| 1253 | }).pipe( | 1357 | }).pipe( |
| @@ -1259,13 +1363,20 @@ export const createRuntimeSourceAuthService = (input: { · 5 unmodified lines | |||
| 1259 | yield* input.rows.sourceAuthSessions.update( | 1363 | yield* input.rows.sourceAuthSessions.update( |
| 1260 | session.id, | 1364 | session.id, |
| 1261 | createTerminalSourceAuthSessionPatch({ | 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 | status: "completed", | 1377 | status: "completed", |
| 1263 | now, | 1378 | now, |
| 1264 | errorText: null, | 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 | import type { | 3 | import type { |
| 2 | CreateSourcePayload, | 4 | CreateSourcePayload, |
| 3 | UpdateSourcePayload, | 5 | UpdateSourcePayload, |
| 4 | } from "#api"; | 6 | } from "#api"; |
| 5 | import type { | 7 | import type { |
| 8 | AccountId, | ||
| 6 | Credential, | 9 | Credential, |
| 7 | Source, | 10 | Source, |
| 8 | SourceAuth, | 11 | SourceAuth, |
| 9 | SourceCredentialBinding, | 12 | SourceRecipeId, |
| 10 | SourceId, | 13 | SourceRecipeImporterKind, |
| 14 | SourceRecipeKind, | ||
| 15 | SourceRecipeRevisionId, | ||
| 11 | StoredSourceRecord, | 16 | StoredSourceRecord, |
| 17 | StoredSourceRecipeRecord, | ||
| 18 | StoredSourceRecipeRevisionRecord, | ||
| 12 | StringMap, | 19 | StringMap, |
| 13 | WorkspaceId, | 20 | WorkspaceId, |
| 14 | } from "#schema"; | 21 | } from "#schema"; |
| 15 | import { CredentialIdSchema } from "#schema"; | 22 | import { |
| 23 | CredentialIdSchema, | ||
| 24 | SourceRecipeIdSchema, | ||
| 25 | SourceRecipeRevisionIdSchema, | ||
| 26 | } from "#schema"; | ||
| 16 | import * as Effect from "effect/Effect"; | 27 | import * as Effect from "effect/Effect"; |
| 17 | import * as Schema from "effect/Schema"; | 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 | const normalizeAuth = ( | 200 | const normalizeAuth = ( |
| 62 | auth: SourceAuth | undefined, | 201 | auth: SourceAuth | undefined, |
| 63 | ): Effect.Effect<SourceAuth, Error, never> => | 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 | export const createSourceFromPayload = (input: { | 315 | export const createSourceFromPayload = (input: { |
| 177 | workspaceId: WorkspaceId; | 316 | workspaceId: WorkspaceId; |
| 178 | sourceId: SourceId; | 317 | sourceId: Source["id"]; |
| 179 | payload: CreateSourcePayload; | 318 | payload: CreateSourcePayload; |
| 180 | now: number; | 319 | now: number; |
| 181 | }): Effect.Effect<Source, Error, never> => | 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 | export const splitSourceForStorage = (input: { | 432 | export const splitSourceForStorage = (input: { |
| 257 | source: Source; | 433 | source: Source; |
| 434 | recipeId: SourceRecipeId; | ||
| 435 | recipeRevisionId: SourceRecipeRevisionId; | ||
| 436 | actorAccountId?: AccountId | null; | ||
| 258 | existingCredentialId?: Credential["id"] | null; | 437 | existingCredentialId?: Credential["id"] | null; |
| 259 | existingBindingId?: SourceCredentialBinding["id"] | null; | ||
| 260 | }): { | 438 | }): { |
| 261 | sourceRecord: StoredSourceRecord; | 439 | sourceRecord: StoredSourceRecord; |
| 262 | credential: Credential | null; | 440 | credential: Credential | null; |
| 263 | credentialBinding: SourceCredentialBinding | null; | ||
| 264 | } => { | 441 | } => { |
| 265 | const sourceRecord: StoredSourceRecord = { | 442 | const sourceRecord: StoredSourceRecord = { |
| 266 | id: input.source.id, | 443 | id: input.source.id, |
| 267 | workspaceId: input.source.workspaceId, | 444 | workspaceId: input.source.workspaceId, |
| 445 | recipeId: input.recipeId, | ||
| 446 | recipeRevisionId: input.recipeRevisionId, | ||
| 268 | name: input.source.name, | 447 | name: input.source.name, |
| 269 | kind: input.source.kind, | 448 | kind: input.source.kind, |
| 270 | endpoint: input.source.endpoint, | 449 | endpoint: input.source.endpoint, |
| 271 | status: input.source.status, | 450 | status: input.source.status, |
| 272 | enabled: input.source.enabled, | 451 | enabled: input.source.enabled, |
| 273 | namespace: input.source.namespace, | 452 | namespace: input.source.namespace, |
| 453 | bindingConfigJson: null, | ||
| 274 | transport: input.source.transport, | 454 | transport: input.source.transport, |
| 275 | queryParamsJson: serializeStringMap(input.source.queryParams), | 455 | queryParamsJson: serializeStringMap(input.source.queryParams), |
| 276 | headersJson: serializeStringMap(input.source.headers), | 456 | headersJson: serializeStringMap(input.source.headers), |
| @@ -287,17 +467,17 @@ export const splitSourceForStorage = (input: { · 10 unmodified lines | |||
| 287 | return { | 467 | return { |
| 288 | sourceRecord, | 468 | sourceRecord, |
| 289 | credential: null, | 469 | credential: null, |
| 290 | credentialBinding: null, | ||
| 291 | }; | 470 | }; |
| 292 | } | 471 | } |
| 293 | 472 | ||
| 294 | const credentialId = input.existingCredentialId | 473 | const credentialId = input.existingCredentialId |
| 295 | ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`); | 474 | ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`); |
| 296 | const bindingId = input.existingBindingId ?? `src_cred_bind_${crypto.randomUUID()}`; | ||
| 297 | 475 | ||
| 298 | const credential: Credential = { | 476 | const credential: Credential = { |
| 299 | id: credentialId, | 477 | id: credentialId, |
| 300 | workspaceId: input.source.workspaceId, | 478 | workspaceId: input.source.workspaceId, |
| 479 | sourceId: input.source.id, | ||
| 480 | actorAccountId: input.actorAccountId ?? null, | ||
| 301 | authKind: input.source.auth.kind, | 481 | authKind: input.source.auth.kind, |
| 302 | authHeaderName: input.source.auth.headerName, | 482 | authHeaderName: input.source.auth.headerName, |
| 303 | authPrefix: input.source.auth.prefix, | 483 | authPrefix: input.source.auth.prefix, |
| @@ -324,20 +504,11 @@ export const splitSourceForStorage = (input: { · 20 unmodified lines | |||
| 324 | return { | 504 | return { |
| 325 | sourceRecord, | 505 | sourceRecord, |
| 326 | credential, | 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 | export const projectSourceFromStorage = (input: { | 510 | export const projectSourceFromStorage = (input: { |
| 339 | sourceRecord: StoredSourceRecord; | 511 | sourceRecord: StoredSourceRecord; |
| 340 | credentialBinding: SourceCredentialBinding | null; | ||
| 341 | credential: Credential | null; | 512 | credential: Credential | null; |
| 342 | }): Effect.Effect<Source, Error, never> => | 513 | }): Effect.Effect<Source, Error, never> => |
| 343 | Effect.gen(function* () { | 514 | Effect.gen(function* () { |
| @@ -355,38 +526,32 @@ export const projectSourceFromStorage = (input: { · 11 unmodified lines | |||
| 355 | ); | 526 | ); |
| 356 | 527 | ||
| 357 | let auth: SourceAuth = { kind: "none" }; | 528 | let auth: SourceAuth = { kind: "none" }; |
| 358 | if (input.credentialBinding !== null) { | 529 | if (input.credential !== null) { |
| 359 | const credential = input.credential; | 530 | if (input.credential.authKind === "bearer") { |
| 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") { | ||
| 367 | auth = { | 531 | auth = { |
| 368 | kind: "bearer", | 532 | kind: "bearer", |
| 369 | headerName: credential.authHeaderName, | 533 | headerName: input.credential.authHeaderName, |
| 370 | prefix: credential.authPrefix, | 534 | prefix: input.credential.authPrefix, |
| 371 | token: { | 535 | token: { |
| 372 | providerId: credential.tokenProviderId, | 536 | providerId: input.credential.tokenProviderId, |
| 373 | handle: credential.tokenHandle, | 537 | handle: input.credential.tokenHandle, |
| 374 | }, | 538 | }, |
| 375 | }; | 539 | }; |
| 376 | } else { | 540 | } else { |
| 377 | auth = { | 541 | auth = { |
| 378 | kind: "oauth2", | 542 | kind: "oauth2", |
| 379 | headerName: credential.authHeaderName, | 543 | headerName: input.credential.authHeaderName, |
| 380 | prefix: credential.authPrefix, | 544 | prefix: input.credential.authPrefix, |
| 381 | accessToken: { | 545 | accessToken: { |
| 382 | providerId: credential.tokenProviderId, | 546 | providerId: input.credential.tokenProviderId, |
| 383 | handle: credential.tokenHandle, | 547 | handle: input.credential.tokenHandle, |
| 384 | }, | 548 | }, |
| 385 | refreshToken: | 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, | 553 | providerId: input.credential.refreshTokenProviderId, |
| 389 | handle: credential.refreshTokenHandle, | 554 | handle: input.credential.refreshTokenHandle, |
| 390 | } | 555 | } |
| 391 | : null, | 556 | : null, |
| 392 | }; | 557 | }; |
| @@ -421,25 +586,20 @@ export const projectSourceFromStorage = (input: { · 28 unmodified lines | |||
| 421 | 586 | ||
| 422 | export const projectSourcesFromStorage = (input: { | 587 | export const projectSourcesFromStorage = (input: { |
| 423 | sourceRecords: ReadonlyArray<StoredSourceRecord>; | 588 | sourceRecords: ReadonlyArray<StoredSourceRecord>; |
| 424 | credentialBindings: ReadonlyArray<SourceCredentialBinding>; | ||
| 425 | credentials: ReadonlyArray<Credential>; | 589 | credentials: ReadonlyArray<Credential>; |
| 426 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { | 590 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { |
| 427 | const bindingsBySourceId = new Map( | 591 | const credentialsBySourceId = new Map<string, Credential>(); |
| 428 | input.credentialBindings.map((binding) => [binding.sourceId, binding]), | 592 | |
| 429 | ); | 593 | for (const credential of input.credentials) { |
| 430 | const credentialsById = new Map( | 594 | const existing = credentialsBySourceId.get(credential.sourceId); |
| 431 | input.credentials.map((credential) => [credential.id, credential]), | 595 | if (!existing || (existing.actorAccountId === null && credential.actorAccountId !== null)) { |
| 432 | ); | 596 | credentialsBySourceId.set(credential.sourceId, credential); |
| 597 | } | ||
| 598 | } | ||
| 433 | 599 | ||
| 434 | return Effect.forEach(input.sourceRecords, (sourceRecord) => { | 600 | return Effect.forEach(input.sourceRecords, (sourceRecord) => |
| 435 | const credentialBinding = bindingsBySourceId.get(sourceRecord.id) ?? null; | 601 | projectSourceFromStorage({ |
| 436 | return projectSourceFromStorage({ | ||
| 437 | sourceRecord, | 602 | sourceRecord, |
| 438 | credentialBinding, | 603 | credential: credentialsBySourceId.get(sourceRecord.id) ?? null, |
| 439 | credential: | 604 | })); |
| 440 | credentialBinding === null | ||
| 441 | ? null | ||
| 442 | : credentialsById.get(credentialBinding.credentialId) ?? null, | ||
| 443 | }); | ||
| 444 | }); | ||
| 445 | }; | 605 | }; |
| @@ -11,11 +11,20 @@ import { · 10 unmodified lines | |||
| 11 | } from "#schema"; | 11 | } from "#schema"; |
| 12 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Option from "effect/Option"; | 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 | import { persistSource, removeSourceById } from "./source-store"; | 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 | createSqlControlPlanePersistence({ | 28 | createSqlControlPlanePersistence({ |
| 20 | localDataDir: ":memory:", | 29 | localDataDir: ":memory:", |
| 21 | }), | 30 | }), |
| @@ -24,27 +33,30 @@ const makePersistence = Effect.acquireRelease( · 2 unmodified lines | |||
| 24 | try: () => persistence.close(), | 33 | try: () => persistence.close(), |
| 25 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), | 34 | catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), |
| 26 | }).pipe(Effect.orDie), | 35 | }).pipe(Effect.orDie), |
| 27 | ); | 36 | ); |
| 28 | 37 | ||
| 29 | const makeOpenApiSource = (input: { | 38 | const makeOpenApiSource = (input: { |
| 30 | workspaceId: Source["workspaceId"]; | 39 | workspaceId: Source["workspaceId"]; |
| 31 | sourceId: Source["id"]; | 40 | sourceId: Source["id"]; |
| 32 | now: number; | 41 | now: number; |
| 33 | updatedAt?: number; | 42 | updatedAt?: number; |
| 43 | name?: string; | ||
| 44 | endpoint?: string; | ||
| 45 | specUrl?: string; | ||
| 34 | auth: Source["auth"]; | 46 | auth: Source["auth"]; |
| 35 | }): Source => ({ | 47 | }): Source => ({ |
| 36 | id: input.sourceId, | 48 | id: input.sourceId, |
| 37 | workspaceId: input.workspaceId, | 49 | workspaceId: input.workspaceId, |
| 38 | name: "GitHub", | 50 | name: input.name ?? "GitHub", |
| 39 | kind: "openapi", | 51 | kind: "openapi", |
| 40 | endpoint: "https://api.github.com", | 52 | endpoint: input.endpoint ?? "https://api.github.com", |
| 41 | status: "connected", | 53 | status: "connected", |
| 42 | enabled: true, | 54 | enabled: true, |
| 43 | namespace: "github", | 55 | namespace: "github", |
| 44 | transport: null, | 56 | transport: null, |
| 45 | queryParams: null, | 57 | queryParams: null, |
| 46 | headers: null, | 58 | headers: null, |
| 47 | specUrl: "https://example.com/openapi.json", | 59 | specUrl: input.specUrl ?? "https://example.com/openapi.json", |
| 48 | defaultHeaders: null, | 60 | defaultHeaders: null, |
| 49 | auth: input.auth, | 61 | auth: input.auth, |
| 50 | sourceHash: null, | 62 | sourceHash: null, |
| @@ -122,21 +134,25 @@ describe("source-store", () => { · 71 unmodified lines | |||
| 122 | id: SourceAuthSessionIdSchema.make("src_auth_source_store"), | 134 | id: SourceAuthSessionIdSchema.make("src_auth_source_store"), |
| 123 | workspaceId, | 135 | workspaceId, |
| 124 | sourceId, | 136 | sourceId, |
| 137 | actorAccountId: accountId, | ||
| 125 | executionId: null, | 138 | executionId: null, |
| 126 | interactionId: null, | 139 | interactionId: null, |
| 127 | strategy: "oauth2_authorization_code", | 140 | providerKind: "mcp_oauth", |
| 128 | status: "pending", | 141 | status: "pending", |
| 129 | endpoint: "https://api.github.com", | ||
| 130 | state: "state_source_store", | 142 | state: "state_source_store", |
| 131 | redirectUri: "http://127.0.0.1/callback", | 143 | sessionDataJson: JSON.stringify({ |
| 132 | scope: null, | 144 | kind: "mcp_oauth", |
| 133 | resourceMetadataUrl: null, | 145 | endpoint: "https://api.github.com", |
| 134 | authorizationServerUrl: null, | 146 | redirectUri: "http://127.0.0.1/callback", |
| 135 | resourceMetadataJson: null, | 147 | scope: null, |
| 136 | authorizationServerMetadataJson: null, | 148 | resourceMetadataUrl: null, |
| 137 | clientInformationJson: null, | 149 | authorizationServerUrl: null, |
| 138 | codeVerifier: "verifier", | 150 | resourceMetadataJson: null, |
| 139 | authorizationUrl: "https://example.com/auth", | 151 | authorizationServerMetadataJson: null, |
| 152 | clientInformationJson: null, | ||
| 153 | codeVerifier: "verifier", | ||
| 154 | authorizationUrl: "https://example.com/auth", | ||
| 155 | }), | ||
| 140 | errorText: null, | 156 | errorText: null, |
| 141 | completedAt: null, | 157 | completedAt: null, |
| 142 | createdAt: now, | 158 | createdAt: now, |
| @@ -177,8 +193,321 @@ describe("source-store", () => { · 34 unmodified lines | |||
| 177 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); | 193 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); |
| 178 | expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0); | 194 | expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 179 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); | 195 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 180 | expect(yield* persistence.rows.sourceCredentialBindings.listByWorkspaceId(workspaceId)).toHaveLength(0); | ||
| 181 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 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 | import type { | 1 | import type { |
| 2 | AccountId, | ||
| 2 | Credential, | 3 | Credential, |
| 3 | SecretRef, | 4 | SecretRef, |
| 4 | Source, | 5 | Source, |
| 6 | SourceRecipeId, | ||
| 7 | SourceRecipeRevisionId, | ||
| 5 | WorkspaceId, | 8 | WorkspaceId, |
| 6 | } from "#schema"; | 9 | } from "#schema"; |
| 7 | import { type SqlControlPlaneRows } from "#persistence"; | 10 | import { type SqlControlPlaneRows } from "#persistence"; |
| @@ -9,8 +12,12 @@ import * as Effect from "effect/Effect"; · 1 unmodified line | |||
| 9 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 10 | 13 | ||
| 11 | import { | 14 | import { |
| 15 | createSourceRecipeRecord, | ||
| 16 | createSourceRecipeRevisionRecord, | ||
| 12 | projectSourceFromStorage, | 17 | projectSourceFromStorage, |
| 13 | projectSourcesFromStorage, | 18 | projectSourcesFromStorage, |
| 19 | stableSourceRecipeId, | ||
| 20 | stableSourceRecipeRevisionId, | ||
| 14 | splitSourceForStorage, | 21 | splitSourceForStorage, |
| 15 | } from "./source-definitions"; | 22 | } from "./source-definitions"; |
| 16 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; | 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 | export const loadSourcesInWorkspace = ( | 86 | export const loadSourcesInWorkspace = ( |
| 58 | rows: SqlControlPlaneRows, | 87 | rows: SqlControlPlaneRows, |
| 59 | workspaceId: WorkspaceId, | 88 | workspaceId: WorkspaceId, |
| 89 | options: { | ||
| 90 | actorAccountId?: AccountId | null; | ||
| 91 | } = {}, | ||
| 60 | ) => | 92 | ) => |
| 61 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 62 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); | 94 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); |
| 63 | const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId); | ||
| 64 | const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId); | 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 | return yield* projectSourcesFromStorage({ | 105 | return yield* projectSourcesFromStorage({ |
| 67 | sourceRecords, | 106 | sourceRecords, |
| 68 | credentialBindings, | 107 | credentials: filteredCredentials, |
| 69 | credentials, | ||
| 70 | }); | 108 | }); |
| 71 | }); | 109 | }); |
| 72 | 110 | ||
| 73 | export const loadSourceById = (rows: SqlControlPlaneRows, input: { | 111 | export const loadSourceById = (rows: SqlControlPlaneRows, input: { |
| 74 | workspaceId: WorkspaceId; | 112 | workspaceId: WorkspaceId; |
| 75 | sourceId: Source["id"]; | 113 | sourceId: Source["id"]; |
| 114 | actorAccountId?: AccountId | null; | ||
| 76 | }) => | 115 | }) => |
| 77 | Effect.gen(function* () { | 116 | Effect.gen(function* () { |
| 78 | const sourceRecord = yield* rows.sources.getByWorkspaceAndId( | 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( | 128 | const credentials = yield* rows.credentials.listByWorkspaceAndSourceId({ |
| 90 | input.workspaceId, | 129 | workspaceId: input.workspaceId, |
| 91 | input.sourceId, | 130 | sourceId: input.sourceId, |
| 92 | ); | 131 | }); |
| 93 | const credential = | 132 | const credential = selectPreferredCredential({ |
| 94 | Option.isSome(credentialBinding) | 133 | credentials, |
| 95 | ? yield* rows.credentials.getById(credentialBinding.value.credentialId) | 134 | actorAccountId: input.actorAccountId, |
| 96 | : Option.none<Credential>(); | 135 | }); |
| 97 | 136 | ||
| 98 | return yield* projectSourceFromStorage({ | 137 | return yield* projectSourceFromStorage({ |
| 99 | sourceRecord: sourceRecord.value, | 138 | sourceRecord: sourceRecord.value, |
| 100 | credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null, | 139 | credential, |
| 101 | credential: Option.isSome(credential) ? credential.value : null, | ||
| 102 | }); | 140 | }); |
| 103 | }); | 141 | }); |
| 104 | 142 | ||
| 105 | export const removeCredentialBindingForSource = (rows: SqlControlPlaneRows, input: { | 143 | const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: { |
| 106 | workspaceId: WorkspaceId; | 144 | workspaceId: WorkspaceId; |
| 107 | sourceId: Source["id"]; | 145 | sourceId: Source["id"]; |
| 108 | }) => | 146 | }) => |
| 109 | Effect.gen(function* () { | 147 | Effect.gen(function* () { |
| 110 | const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId( | 148 | const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({ |
| 111 | input.workspaceId, | 149 | workspaceId: input.workspaceId, |
| 112 | input.sourceId, | 150 | sourceId: input.sourceId, |
| 113 | ); | 151 | }); |
| 114 | |||
| 115 | if (Option.isNone(existingBinding)) { | ||
| 116 | return false; | ||
| 117 | } | ||
| 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( | 158 | yield* Effect.forEach( |
| 122 | input.workspaceId, | 159 | existingCredentials, |
| 123 | input.sourceId, | 160 | (credential) => |
| 161 | cleanupCredentialSecretRefs(rows, { | ||
| 162 | previous: credential, | ||
| 163 | next: null, | ||
| 164 | }), | ||
| 165 | { discard: true }, | ||
| 124 | ); | 166 | ); |
| 125 | 167 | ||
| 126 | if (Option.isSome(existingCredential)) { | 168 | return existingCredentials.length; |
| 127 | yield* rows.credentials.removeById(existingBinding.value.credentialId); | 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, { | 184 | const recipeReferenceCount = yield* rows.sources.countByRecipeId(input.recipeId); |
| 131 | previous: Option.isSome(existingCredential) ? existingCredential.value : null, | 185 | if (recipeReferenceCount > 0) { |
| 132 | next: null, | 186 | return; |
| 133 | }); | 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 | export const removeSourceById = (rows: SqlControlPlaneRows, input: { | 203 | export const removeSourceById = (rows: SqlControlPlaneRows, input: { |
| @@ -140,34 +205,81 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 1 unmodified line | |||
| 140 | sourceId: Source["id"]; | 205 | sourceId: Source["id"]; |
| 141 | }) => | 206 | }) => |
| 142 | Effect.gen(function* () { | 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 | yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId( | 213 | yield* rows.sourceAuthSessions.removeByWorkspaceAndSourceId( |
| 144 | input.workspaceId, | 214 | input.workspaceId, |
| 145 | input.sourceId, | 215 | input.sourceId, |
| 146 | ); | 216 | ); |
| 147 | yield* removeCredentialBindingForSource(rows, input); | 217 | yield* rows.sourceOauthClients.removeByWorkspaceAndSourceId({ |
| 148 | return yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId); | 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 | Effect.gen(function* () { | 242 | Effect.gen(function* () { |
| 153 | const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id); | 243 | const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id); |
| 154 | const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId( | 244 | const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({ |
| 155 | source.workspaceId, | 245 | workspaceId: source.workspaceId, |
| 156 | source.id, | 246 | sourceId: source.id, |
| 157 | ); | 247 | }); |
| 158 | const existingCredential = Option.isSome(existingBinding) | 248 | const existingCredential = selectExactCredential({ |
| 159 | ? yield* rows.credentials.getById(existingBinding.value.credentialId) | 249 | credentials: existingCredentials, |
| 160 | : Option.none<Credential>(); | 250 | actorAccountId: options.actorAccountId, |
| 161 | const existingCredentialId = Option.isSome(existingBinding) | 251 | }); |
| 162 | ? existingBinding.value.credentialId | 252 | |
| 163 | : null; | 253 | const nextRecipeId = stableSourceRecipeId(source); |
| 164 | const existingBindingId = Option.isSome(existingBinding) | 254 | const nextRecipeRevisionId = stableSourceRecipeRevisionId(source); |
| 165 | ? existingBinding.value.id | 255 | const existingTargetRevision = yield* rows.sourceRecipeRevisions.getById(nextRecipeRevisionId); |
| 166 | : null; | 256 | const nextRevision = createSourceRecipeRevisionRecord({ |
| 167 | const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({ | ||
| 168 | source, | 257 | source, |
| 169 | existingCredentialId, | 258 | recipeId: nextRecipeId, |
| 170 | existingBindingId, | 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 | if (Option.isNone(existing)) { | 285 | if (Option.isNone(existing)) { |
| @@ -177,30 +289,39 @@ export const persistSource = (rows: SqlControlPlaneRows, source: Source) => · 3 unmodified lines | |||
| 177 | id: _id, | 289 | id: _id, |
| 178 | workspaceId: _workspaceId, | 290 | workspaceId: _workspaceId, |
| 179 | createdAt: _createdAt, | 291 | createdAt: _createdAt, |
| 180 | sourceDocumentText: _sourceDocumentText, | ||
| 181 | ...patch | 292 | ...patch |
| 182 | } = sourceRecord; | 293 | } = sourceRecord; |
| 183 | yield* rows.sources.update(source.workspaceId, source.id, patch); | 294 | yield* rows.sources.update(source.workspaceId, source.id, patch); |
| 184 | } | 295 | } |
| 185 | 296 | ||
| 186 | if (credential === null || credentialBinding === null) { | 297 | yield* rows.sourceRecipes.upsert(nextRecipe); |
| 187 | if (Option.isSome(existingBinding)) { | 298 | yield* rows.sourceRecipeRevisions.upsert(nextRevision); |
| 188 | yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId( | 299 | |
| 189 | source.workspaceId, | 300 | if ( |
| 190 | source.id, | 301 | Option.isSome(existing) |
| 191 | ); | 302 | && ( |
| 192 | 303 | existing.value.recipeId !== nextRecipeId | |
| 193 | if (Option.isSome(existingCredential)) { | 304 | || existing.value.recipeRevisionId !== nextRecipeRevisionId |
| 194 | yield* rows.credentials.removeById(existingBinding.value.credentialId); | 305 | ) |
| 195 | } | 306 | ) { |
| 196 | } | 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 | } else { | 319 | } else { |
| 198 | yield* rows.credentials.upsert(credential); | 320 | yield* rows.credentials.upsert(credential); |
| 199 | yield* rows.sourceCredentialBindings.upsert(credentialBinding); | ||
| 200 | } | 321 | } |
| 201 | 322 | ||
| 202 | yield* cleanupCredentialSecretRefs(rows, { | 323 | yield* cleanupCredentialSecretRefs(rows, { |
| 203 | previous: Option.isSome(existingCredential) ? existingCredential.value : null, | 324 | previous: existingCredential ?? null, |
| 204 | next: credential, | 325 | next: credential, |
| 205 | }); | 326 | }); |
| 206 | 327 | ||
| @@ -20,6 +20,22 @@ export { · 19 unmodified lines | |||
| 20 | type SourceStatus, | 20 | type SourceStatus, |
| 21 | type SourceTransport, | 21 | type SourceTransport, |
| 22 | } from "./models/source"; | 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 | export { | 39 | export { |
| 24 | SourceAuthInferenceSchema, | 40 | SourceAuthInferenceSchema, |
| 25 | SourceDiscoveryAuthKindSchema, | 41 | SourceDiscoveryAuthKindSchema, |
| @@ -40,6 +56,12 @@ export { · 14 unmodified lines | |||
| 40 | CredentialAuthKindSchema, | 56 | CredentialAuthKindSchema, |
| 41 | type CredentialAuthKind, | 57 | type CredentialAuthKind, |
| 42 | } from "./models/credential"; | 58 | } from "./models/credential"; |
| 59 | export { | ||
| 60 | SourceAuthSessionProviderKindSchema, | ||
| 61 | SourceAuthSessionStatusSchema, | ||
| 62 | type SourceAuthSessionProviderKind, | ||
| 63 | type SourceAuthSessionStatus, | ||
| 64 | } from "./models/source-auth-session"; | ||
| 43 | export { | 65 | export { |
| 44 | PolicyApprovalModeSchema, | 66 | PolicyApprovalModeSchema, |
| 45 | PolicyEffectSchema, | 67 | PolicyEffectSchema, |
| @@ -7,12 +7,19 @@ export const OrganizationMemberIdSchema = Schema.String.pipe( · 6 unmodified lines | |||
| 7 | ); | 7 | ); |
| 8 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); | 8 | export const WorkspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceId")); |
| 9 | export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); | 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 | export const SourceAuthSessionIdSchema = Schema.String.pipe( | 14 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 11 | Schema.brand("SourceAuthSessionId"), | 15 | Schema.brand("SourceAuthSessionId"), |
| 12 | ); | 16 | ); |
| 13 | export const CredentialIdSchema = Schema.String.pipe( | 17 | export const CredentialIdSchema = Schema.String.pipe( |
| 14 | Schema.brand("CredentialId"), | 18 | Schema.brand("CredentialId"), |
| 15 | ); | 19 | ); |
| 20 | export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe( | ||
| 21 | Schema.brand("WorkspaceSourceOauthClientId"), | ||
| 22 | ); | ||
| 16 | export const SecretMaterialIdSchema = Schema.String.pipe( | 23 | export const SecretMaterialIdSchema = Schema.String.pipe( |
| 17 | Schema.brand("SecretMaterialId"), | 24 | Schema.brand("SecretMaterialId"), |
| 18 | ); | 25 | ); |
| @@ -28,8 +35,11 @@ export type OrganizationId = typeof OrganizationIdSchema.Type; · 9 unmodified lines | |||
| 28 | export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; | 35 | export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; |
| 29 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; | 36 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 30 | export type SourceId = typeof SourceIdSchema.Type; | 37 | export type SourceId = typeof SourceIdSchema.Type; |
| 38 | export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; | ||
| 39 | export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; | ||
| 31 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; | 40 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 32 | export type CredentialId = typeof CredentialIdSchema.Type; | 41 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 42 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; | ||
| 33 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; | 43 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 34 | export type PolicyId = typeof PolicyIdSchema.Type; | 44 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 35 | export type InstallationId = typeof InstallationIdSchema.Type; | 45 | export type InstallationId = typeof InstallationIdSchema.Type; |
| @@ -5,7 +5,9 @@ export * from "./ids"; · 4 unmodified lines | |||
| 5 | export * from "./models/auth"; | 5 | export * from "./models/auth"; |
| 6 | export * from "./models/workspace"; | 6 | export * from "./models/workspace"; |
| 7 | export * from "./models/source"; | 7 | export * from "./models/source"; |
| 8 | export * from "./models/source-recipe"; | ||
| 8 | export * from "./models/credential"; | 9 | export * from "./models/credential"; |
| 10 | export * from "./models/source-oauth-client"; | ||
| 9 | export * from "./models/tool-artifact"; | 11 | export * from "./models/tool-artifact"; |
| 10 | export * from "./models/secret-material"; | 12 | export * from "./models/secret-material"; |
| 11 | export * from "./models/source-auth-session"; | 13 | export * from "./models/source-auth-session"; |
| @@ -1,12 +1,10 @@ | |||
| 1 | import { createSelectSchema } from "drizzle-orm/effect-schema"; | 1 | import { createSelectSchema } from "drizzle-orm/effect-schema"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { credentialsTable } from "../../persistence/schema"; |
| 5 | credentialsTable, | ||
| 6 | sourceCredentialBindingsTable, | ||
| 7 | } from "../../persistence/schema"; | ||
| 8 | import { TimestampMsSchema } from "../common"; | 5 | import { TimestampMsSchema } from "../common"; |
| 9 | import { | 6 | import { |
| 7 | AccountIdSchema, | ||
| 10 | CredentialIdSchema, | 8 | CredentialIdSchema, |
| 11 | SourceIdSchema, | 9 | SourceIdSchema, |
| 12 | WorkspaceIdSchema, | 10 | WorkspaceIdSchema, |
| @@ -17,6 +15,8 @@ export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); · 4 unmodified lines | |||
| 17 | const credentialSchemaOverrides = { | 15 | const credentialSchemaOverrides = { |
| 18 | id: CredentialIdSchema, | 16 | id: CredentialIdSchema, |
| 19 | workspaceId: WorkspaceIdSchema, | 17 | workspaceId: WorkspaceIdSchema, |
| 18 | sourceId: SourceIdSchema, | ||
| 19 | actorAccountId: Schema.NullOr(AccountIdSchema), | ||
| 20 | authKind: CredentialAuthKindSchema, | 20 | authKind: CredentialAuthKindSchema, |
| 21 | authHeaderName: Schema.String, | 21 | authHeaderName: Schema.String, |
| 22 | authPrefix: Schema.String, | 22 | authPrefix: Schema.String, |
| @@ -28,25 +28,10 @@ const credentialSchemaOverrides = { · 5 unmodified lines | |||
| 28 | updatedAt: TimestampMsSchema, | 28 | updatedAt: TimestampMsSchema, |
| 29 | } as const; | 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 | export const CredentialSchema = createSelectSchema( | 31 | export const CredentialSchema = createSelectSchema( |
| 41 | credentialsTable, | 32 | credentialsTable, |
| 42 | credentialSchemaOverrides, | 33 | credentialSchemaOverrides, |
| 43 | ); | 34 | ); |
| 44 | 35 | ||
| 45 | export const SourceCredentialBindingSchema = createSelectSchema( | ||
| 46 | sourceCredentialBindingsTable, | ||
| 47 | sourceCredentialBindingSchemaOverrides, | ||
| 48 | ); | ||
| 49 | |||
| 50 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; | 36 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; |
| 51 | export type Credential = typeof CredentialSchema.Type; | 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 | import { sourceAuthSessionsTable } from "../../persistence/schema"; | 4 | import { sourceAuthSessionsTable } from "../../persistence/schema"; |
| 5 | import { TimestampMsSchema } from "../common"; | 5 | import { TimestampMsSchema } from "../common"; |
| 6 | import { | 6 | import { |
| 7 | AccountIdSchema, | ||
| 7 | ExecutionIdSchema, | 8 | ExecutionIdSchema, |
| 8 | ExecutionInteractionIdSchema, | 9 | ExecutionInteractionIdSchema, |
| 9 | SourceAuthSessionIdSchema, | 10 | SourceAuthSessionIdSchema, |
| @@ -11,8 +12,9 @@ import { · 1 unmodified line | |||
| 11 | WorkspaceIdSchema, | 12 | WorkspaceIdSchema, |
| 12 | } from "../ids"; | 13 | } from "../ids"; |
| 13 | 14 | ||
| 14 | export const SourceAuthSessionStrategySchema = Schema.Literal( | 15 | export const SourceAuthSessionProviderKindSchema = Schema.Literal( |
| 15 | "oauth2_authorization_code", | 16 | "mcp_oauth", |
| 17 | "oauth2_pkce", | ||
| 16 | ); | 18 | ); |
| 17 | 19 | ||
| 18 | export const SourceAuthSessionStatusSchema = Schema.Literal( | 20 | export const SourceAuthSessionStatusSchema = Schema.Literal( |
| @@ -26,9 +28,10 @@ const sourceAuthSessionSchemaOverrides = { · 7 unmodified lines | |||
| 26 | id: SourceAuthSessionIdSchema, | 28 | id: SourceAuthSessionIdSchema, |
| 27 | workspaceId: WorkspaceIdSchema, | 29 | workspaceId: WorkspaceIdSchema, |
| 28 | sourceId: SourceIdSchema, | 30 | sourceId: SourceIdSchema, |
| 31 | actorAccountId: Schema.NullOr(AccountIdSchema), | ||
| 29 | executionId: Schema.NullOr(ExecutionIdSchema), | 32 | executionId: Schema.NullOr(ExecutionIdSchema), |
| 30 | interactionId: Schema.NullOr(ExecutionInteractionIdSchema), | 33 | interactionId: Schema.NullOr(ExecutionInteractionIdSchema), |
| 31 | strategy: SourceAuthSessionStrategySchema, | 34 | providerKind: SourceAuthSessionProviderKindSchema, |
| 32 | status: SourceAuthSessionStatusSchema, | 35 | status: SourceAuthSessionStatusSchema, |
| 33 | completedAt: Schema.NullOr(TimestampMsSchema), | 36 | completedAt: Schema.NullOr(TimestampMsSchema), |
| 34 | createdAt: TimestampMsSchema, | 37 | createdAt: TimestampMsSchema, |
| @@ -40,6 +43,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 5 unmodified lines | |||
| 40 | sourceAuthSessionSchemaOverrides, | 43 | sourceAuthSessionSchemaOverrides, |
| 41 | ); | 44 | ); |
| 42 | 45 | ||
| 43 | export type SourceAuthSessionStrategy = typeof SourceAuthSessionStrategySchema.Type; | 46 | export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type; |
| 44 | export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; | 47 | export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; |
| 45 | export type SourceAuthSession = typeof SourceAuthSessionSchema.Type; | 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 | import { sourcesTable } from "../../persistence/schema"; | 6 | import { sourcesTable } from "../../persistence/schema"; |
| 7 | import { TimestampMsSchema } from "../common"; | 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 | export const SourceKindSchema = Schema.Literal( | 15 | export const SourceKindSchema = Schema.Literal( |
| 11 | "mcp", | 16 | "mcp", |
| @@ -60,6 +65,8 @@ export const StringMapSchema = Schema.Record({ · 48 unmodified lines | |||
| 60 | const sourceRowSchemaOverrides = { | 65 | const sourceRowSchemaOverrides = { |
| 61 | workspaceId: WorkspaceIdSchema, | 66 | workspaceId: WorkspaceIdSchema, |
| 62 | sourceId: SourceIdSchema, | 67 | sourceId: SourceIdSchema, |
| 68 | recipeId: SourceRecipeIdSchema, | ||
| 69 | recipeRevisionId: SourceRecipeRevisionIdSchema, | ||
| 63 | kind: SourceKindSchema, | 70 | kind: SourceKindSchema, |
| 64 | status: SourceStatusSchema, | 71 | status: SourceStatusSchema, |
| 65 | transport: Schema.NullOr(SourceTransportSchema), | 72 | transport: Schema.NullOr(SourceTransportSchema), |
| @@ -74,12 +81,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines | |||
| 74 | Schema.Struct({ | 81 | Schema.Struct({ |
| 75 | id: SourceIdSchema, | 82 | id: SourceIdSchema, |
| 76 | workspaceId: WorkspaceIdSchema, | 83 | workspaceId: WorkspaceIdSchema, |
| 84 | recipeId: SourceRecipeIdSchema, | ||
| 85 | recipeRevisionId: SourceRecipeRevisionIdSchema, | ||
| 77 | name: Schema.String, | 86 | name: Schema.String, |
| 78 | kind: SourceKindSchema, | 87 | kind: SourceKindSchema, |
| 79 | endpoint: Schema.String, | 88 | endpoint: Schema.String, |
| 80 | status: SourceStatusSchema, | 89 | status: SourceStatusSchema, |
| 81 | enabled: Schema.Boolean, | 90 | enabled: Schema.Boolean, |
| 82 | namespace: Schema.NullOr(Schema.String), | 91 | namespace: Schema.NullOr(Schema.String), |
| 92 | bindingConfigJson: Schema.NullOr(Schema.String), | ||
| 83 | transport: Schema.NullOr(SourceTransportSchema), | 93 | transport: Schema.NullOr(SourceTransportSchema), |
| 84 | queryParamsJson: Schema.NullOr(Schema.String), | 94 | queryParamsJson: Schema.NullOr(Schema.String), |
| 85 | headersJson: Schema.NullOr(Schema.String), | 95 | headersJson: Schema.NullOr(Schema.String), |
| @@ -96,12 +106,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 10 unmodified lines | |||
| 96 | decode: (row, _input) => ({ | 106 | decode: (row, _input) => ({ |
| 97 | id: row.sourceId, | 107 | id: row.sourceId, |
| 98 | workspaceId: row.workspaceId, | 108 | workspaceId: row.workspaceId, |
| 109 | recipeId: row.recipeId, | ||
| 110 | recipeRevisionId: row.recipeRevisionId, | ||
| 99 | name: row.name, | 111 | name: row.name, |
| 100 | kind: row.kind, | 112 | kind: row.kind, |
| 101 | endpoint: row.endpoint, | 113 | endpoint: row.endpoint, |
| 102 | status: row.status, | 114 | status: row.status, |
| 103 | enabled: row.enabled, | 115 | enabled: row.enabled, |
| 104 | namespace: row.namespace, | 116 | namespace: row.namespace, |
| 117 | bindingConfigJson: row.bindingConfigJson, | ||
| 105 | transport: row.transport, | 118 | transport: row.transport, |
| 106 | queryParamsJson: row.queryParamsJson, | 119 | queryParamsJson: row.queryParamsJson, |
| 107 | headersJson: row.headersJson, | 120 | headersJson: row.headersJson, |
| @@ -116,12 +129,15 @@ export const StoredSourceRecordSchema = Schema.transform( · 8 unmodified lines | |||
| 116 | encode: (source, _output) => ({ | 129 | encode: (source, _output) => ({ |
| 117 | workspaceId: source.workspaceId, | 130 | workspaceId: source.workspaceId, |
| 118 | sourceId: source.id, | 131 | sourceId: source.id, |
| 132 | recipeId: source.recipeId, | ||
| 133 | recipeRevisionId: source.recipeRevisionId, | ||
| 119 | name: source.name, | 134 | name: source.name, |
| 120 | kind: source.kind, | 135 | kind: source.kind, |
| 121 | endpoint: source.endpoint, | 136 | endpoint: source.endpoint, |
| 122 | status: source.status, | 137 | status: source.status, |
| 123 | enabled: source.enabled, | 138 | enabled: source.enabled, |
| 124 | namespace: source.namespace, | 139 | namespace: source.namespace, |
| 140 | bindingConfigJson: source.bindingConfigJson, | ||
| 125 | transport: source.transport, | 141 | transport: source.transport, |
| 126 | queryParamsJson: source.queryParamsJson, | 142 | queryParamsJson: source.queryParamsJson, |
| 127 | headersJson: source.headersJson, | 143 | headersJson: source.headersJson, |