History

openapi: refresh sources by re-fetching their origin URL

Persist the spec's origin URL on `openapi_source` so sources added via http(s) surface `canRefresh: true` and can be re-fetched via `sources.refresh(id)`. Hoists `addSpec`'s parse → extract → upsert path into a shared `rebuildSource(ctx, input)` helper that the new `refreshSource` plugin hook replays with the stored URL. Raw-text sources keep `canRefresh: false` and no-op on refresh. Includes cloud node test covering URL-backed refresh and the raw-text no-op branch, plus the drizzle migration for the new `source_url` column.

Rhys Sullivan committed 6 months ago

Commit 16dc8b81 parent 79848da7 files · +1635 −83Browse files

Unified · Split

7 files
  1. apps/cloud/drizzle/0001_harsh_meltdown.sql +1 −0
  2. apps/cloud/drizzle/meta/0001_snapshot.json +1276 −0
  3. apps/cloud/drizzle/meta/_journal.json +7 −0
  4. apps/cloud/src/services/executor-schema.ts +2 −0
  5. apps/cloud/src/services/sources-refresh.node.test.ts +177 −0
  6. packages/plugins/openapi/src/sdk/plugin.ts +161 −83
  7. packages/plugins/openapi/src/sdk/store.ts +11 −0
apps/cloud/drizzle/0001_harsh_meltdown.sql+1−0
@@ -0,0 +1 @@
1ALTER TABLE "openapi_source" ADD COLUMN "source_url" text;
apps/cloud/drizzle/meta/0001_snapshot.json+1276−0
@@ -0,0 +1,1276 @@
1{
2 "id": "12ebb69b-fb44-4e61-80b9-a48b5471ef5a",
3 "prevId": "93e9f8ef-fe9a-4a8c-b326-329464d876ca",
4 "version": "7",
5 "dialect": "postgresql",
6 "tables": {
7 "public.accounts": {
8 "name": "accounts",
9 "schema": "",
10 "columns": {
11 "id": {
12 "name": "id",
13 "type": "text",
14 "primaryKey": true,
15 "notNull": true
16 },
17 "created_at": {
18 "name": "created_at",
19 "type": "timestamp with time zone",
20 "primaryKey": false,
21 "notNull": true,
22 "default": "now()"
23 }
24 },
25 "indexes": {},
26 "foreignKeys": {},
27 "compositePrimaryKeys": {},
28 "uniqueConstraints": {},
29 "policies": {},
30 "checkConstraints": {},
31 "isRLSEnabled": false
32 },
33 "public.memberships": {
34 "name": "memberships",
35 "schema": "",
36 "columns": {
37 "account_id": {
38 "name": "account_id",
39 "type": "text",
40 "primaryKey": false,
41 "notNull": true
42 },
43 "organization_id": {
44 "name": "organization_id",
45 "type": "text",
46 "primaryKey": false,
47 "notNull": true
48 },
49 "created_at": {
50 "name": "created_at",
51 "type": "timestamp with time zone",
52 "primaryKey": false,
53 "notNull": true,
54 "default": "now()"
55 }
56 },
57 "indexes": {},
58 "foreignKeys": {
59 "memberships_account_id_accounts_id_fk": {
60 "name": "memberships_account_id_accounts_id_fk",
61 "tableFrom": "memberships",
62 "tableTo": "accounts",
63 "columnsFrom": [
64 "account_id"
65 ],
66 "columnsTo": [
67 "id"
68 ],
69 "onDelete": "cascade",
70 "onUpdate": "no action"
71 },
72 "memberships_organization_id_organizations_id_fk": {
73 "name": "memberships_organization_id_organizations_id_fk",
74 "tableFrom": "memberships",
75 "tableTo": "organizations",
76 "columnsFrom": [
77 "organization_id"
78 ],
79 "columnsTo": [
80 "id"
81 ],
82 "onDelete": "cascade",
83 "onUpdate": "no action"
84 }
85 },
86 "compositePrimaryKeys": {
87 "memberships_account_id_organization_id_pk": {
88 "name": "memberships_account_id_organization_id_pk",
89 "columns": [
90 "account_id",
91 "organization_id"
92 ]
93 }
94 },
95 "uniqueConstraints": {},
96 "policies": {},
97 "checkConstraints": {},
98 "isRLSEnabled": false
99 },
100 "public.organizations": {
101 "name": "organizations",
102 "schema": "",
103 "columns": {
104 "id": {
105 "name": "id",
106 "type": "text",
107 "primaryKey": true,
108 "notNull": true
109 },
110 "name": {
111 "name": "name",
112 "type": "text",
113 "primaryKey": false,
114 "notNull": true
115 },
116 "created_at": {
117 "name": "created_at",
118 "type": "timestamp with time zone",
119 "primaryKey": false,
120 "notNull": true,
121 "default": "now()"
122 }
123 },
124 "indexes": {},
125 "foreignKeys": {},
126 "compositePrimaryKeys": {},
127 "uniqueConstraints": {},
128 "policies": {},
129 "checkConstraints": {},
130 "isRLSEnabled": false
131 },
132 "public.blob": {
133 "name": "blob",
134 "schema": "",
135 "columns": {
136 "namespace": {
137 "name": "namespace",
138 "type": "text",
139 "primaryKey": false,
140 "notNull": true
141 },
142 "key": {
143 "name": "key",
144 "type": "text",
145 "primaryKey": false,
146 "notNull": true
147 },
148 "value": {
149 "name": "value",
150 "type": "text",
151 "primaryKey": false,
152 "notNull": true
153 }
154 },
155 "indexes": {},
156 "foreignKeys": {},
157 "compositePrimaryKeys": {
158 "blob_namespace_key_pk": {
159 "name": "blob_namespace_key_pk",
160 "columns": [
161 "namespace",
162 "key"
163 ]
164 }
165 },
166 "uniqueConstraints": {},
167 "policies": {},
168 "checkConstraints": {},
169 "isRLSEnabled": false
170 },
171 "public.definition": {
172 "name": "definition",
173 "schema": "",
174 "columns": {
175 "id": {
176 "name": "id",
177 "type": "text",
178 "primaryKey": false,
179 "notNull": true
180 },
181 "scope_id": {
182 "name": "scope_id",
183 "type": "text",
184 "primaryKey": false,
185 "notNull": true
186 },
187 "source_id": {
188 "name": "source_id",
189 "type": "text",
190 "primaryKey": false,
191 "notNull": true
192 },
193 "plugin_id": {
194 "name": "plugin_id",
195 "type": "text",
196 "primaryKey": false,
197 "notNull": true
198 },
199 "name": {
200 "name": "name",
201 "type": "text",
202 "primaryKey": false,
203 "notNull": true
204 },
205 "schema": {
206 "name": "schema",
207 "type": "jsonb",
208 "primaryKey": false,
209 "notNull": true
210 },
211 "created_at": {
212 "name": "created_at",
213 "type": "timestamp",
214 "primaryKey": false,
215 "notNull": true
216 }
217 },
218 "indexes": {
219 "definition_scope_id_idx": {
220 "name": "definition_scope_id_idx",
221 "columns": [
222 {
223 "expression": "scope_id",
224 "isExpression": false,
225 "asc": true,
226 "nulls": "last"
227 }
228 ],
229 "isUnique": false,
230 "concurrently": false,
231 "method": "btree",
232 "with": {}
233 },
234 "definition_source_id_idx": {
235 "name": "definition_source_id_idx",
236 "columns": [
237 {
238 "expression": "source_id",
239 "isExpression": false,
240 "asc": true,
241 "nulls": "last"
242 }
243 ],
244 "isUnique": false,
245 "concurrently": false,
246 "method": "btree",
247 "with": {}
248 },
249 "definition_plugin_id_idx": {
250 "name": "definition_plugin_id_idx",
251 "columns": [
252 {
253 "expression": "plugin_id",
254 "isExpression": false,
255 "asc": true,
256 "nulls": "last"
257 }
258 ],
259 "isUnique": false,
260 "concurrently": false,
261 "method": "btree",
262 "with": {}
263 }
264 },
265 "foreignKeys": {},
266 "compositePrimaryKeys": {
267 "definition_scope_id_id_pk": {
268 "name": "definition_scope_id_id_pk",
269 "columns": [
270 "scope_id",
271 "id"
272 ]
273 }
274 },
275 "uniqueConstraints": {},
276 "policies": {},
277 "checkConstraints": {},
278 "isRLSEnabled": false
279 },
280 "public.graphql_operation": {
281 "name": "graphql_operation",
282 "schema": "",
283 "columns": {
284 "id": {
285 "name": "id",
286 "type": "text",
287 "primaryKey": false,
288 "notNull": true
289 },
290 "scope_id": {
291 "name": "scope_id",
292 "type": "text",
293 "primaryKey": false,
294 "notNull": true
295 },
296 "source_id": {
297 "name": "source_id",
298 "type": "text",
299 "primaryKey": false,
300 "notNull": true
301 },
302 "binding": {
303 "name": "binding",
304 "type": "jsonb",
305 "primaryKey": false,
306 "notNull": true
307 }
308 },
309 "indexes": {
310 "graphql_operation_scope_id_idx": {
311 "name": "graphql_operation_scope_id_idx",
312 "columns": [
313 {
314 "expression": "scope_id",
315 "isExpression": false,
316 "asc": true,
317 "nulls": "last"
318 }
319 ],
320 "isUnique": false,
321 "concurrently": false,
322 "method": "btree",
323 "with": {}
324 },
325 "graphql_operation_source_id_idx": {
326 "name": "graphql_operation_source_id_idx",
327 "columns": [
328 {
329 "expression": "source_id",
330 "isExpression": false,
331 "asc": true,
332 "nulls": "last"
333 }
334 ],
335 "isUnique": false,
336 "concurrently": false,
337 "method": "btree",
338 "with": {}
339 }
340 },
341 "foreignKeys": {},
342 "compositePrimaryKeys": {
343 "graphql_operation_scope_id_id_pk": {
344 "name": "graphql_operation_scope_id_id_pk",
345 "columns": [
346 "scope_id",
347 "id"
348 ]
349 }
350 },
351 "uniqueConstraints": {},
352 "policies": {},
353 "checkConstraints": {},
354 "isRLSEnabled": false
355 },
356 "public.graphql_source": {
357 "name": "graphql_source",
358 "schema": "",
359 "columns": {
360 "id": {
361 "name": "id",
362 "type": "text",
363 "primaryKey": false,
364 "notNull": true
365 },
366 "scope_id": {
367 "name": "scope_id",
368 "type": "text",
369 "primaryKey": false,
370 "notNull": true
371 },
372 "name": {
373 "name": "name",
374 "type": "text",
375 "primaryKey": false,
376 "notNull": true
377 },
378 "endpoint": {
379 "name": "endpoint",
380 "type": "text",
381 "primaryKey": false,
382 "notNull": true
383 },
384 "headers": {
385 "name": "headers",
386 "type": "jsonb",
387 "primaryKey": false,
388 "notNull": false
389 }
390 },
391 "indexes": {
392 "graphql_source_scope_id_idx": {
393 "name": "graphql_source_scope_id_idx",
394 "columns": [
395 {
396 "expression": "scope_id",
397 "isExpression": false,
398 "asc": true,
399 "nulls": "last"
400 }
401 ],
402 "isUnique": false,
403 "concurrently": false,
404 "method": "btree",
405 "with": {}
406 }
407 },
408 "foreignKeys": {},
409 "compositePrimaryKeys": {
410 "graphql_source_scope_id_id_pk": {
411 "name": "graphql_source_scope_id_id_pk",
412 "columns": [
413 "scope_id",
414 "id"
415 ]
416 }
417 },
418 "uniqueConstraints": {},
419 "policies": {},
420 "checkConstraints": {},
421 "isRLSEnabled": false
422 },
423 "public.mcp_binding": {
424 "name": "mcp_binding",
425 "schema": "",
426 "columns": {
427 "id": {
428 "name": "id",
429 "type": "text",
430 "primaryKey": false,
431 "notNull": true
432 },
433 "scope_id": {
434 "name": "scope_id",
435 "type": "text",
436 "primaryKey": false,
437 "notNull": true
438 },
439 "source_id": {
440 "name": "source_id",
441 "type": "text",
442 "primaryKey": false,
443 "notNull": true
444 },
445 "binding": {
446 "name": "binding",
447 "type": "jsonb",
448 "primaryKey": false,
449 "notNull": true
450 },
451 "created_at": {
452 "name": "created_at",
453 "type": "timestamp",
454 "primaryKey": false,
455 "notNull": true
456 }
457 },
458 "indexes": {
459 "mcp_binding_scope_id_idx": {
460 "name": "mcp_binding_scope_id_idx",
461 "columns": [
462 {
463 "expression": "scope_id",
464 "isExpression": false,
465 "asc": true,
466 "nulls": "last"
467 }
468 ],
469 "isUnique": false,
470 "concurrently": false,
471 "method": "btree",
472 "with": {}
473 },
474 "mcp_binding_source_id_idx": {
475 "name": "mcp_binding_source_id_idx",
476 "columns": [
477 {
478 "expression": "source_id",
479 "isExpression": false,
480 "asc": true,
481 "nulls": "last"
482 }
483 ],
484 "isUnique": false,
485 "concurrently": false,
486 "method": "btree",
487 "with": {}
488 }
489 },
490 "foreignKeys": {},
491 "compositePrimaryKeys": {
492 "mcp_binding_scope_id_id_pk": {
493 "name": "mcp_binding_scope_id_id_pk",
494 "columns": [
495 "scope_id",
496 "id"
497 ]
498 }
499 },
500 "uniqueConstraints": {},
501 "policies": {},
502 "checkConstraints": {},
503 "isRLSEnabled": false
504 },
505 "public.mcp_oauth_session": {
506 "name": "mcp_oauth_session",
507 "schema": "",
508 "columns": {
509 "id": {
510 "name": "id",
511 "type": "text",
512 "primaryKey": false,
513 "notNull": true
514 },
515 "scope_id": {
516 "name": "scope_id",
517 "type": "text",
518 "primaryKey": false,
519 "notNull": true
520 },
521 "session": {
522 "name": "session",
523 "type": "jsonb",
524 "primaryKey": false,
525 "notNull": true
526 },
527 "expires_at": {
528 "name": "expires_at",
529 "type": "integer",
530 "primaryKey": false,
531 "notNull": true
532 },
533 "created_at": {
534 "name": "created_at",
535 "type": "timestamp",
536 "primaryKey": false,
537 "notNull": true
538 }
539 },
540 "indexes": {
541 "mcp_oauth_session_scope_id_idx": {
542 "name": "mcp_oauth_session_scope_id_idx",
543 "columns": [
544 {
545 "expression": "scope_id",
546 "isExpression": false,
547 "asc": true,
548 "nulls": "last"
549 }
550 ],
551 "isUnique": false,
552 "concurrently": false,
553 "method": "btree",
554 "with": {}
555 }
556 },
557 "foreignKeys": {},
558 "compositePrimaryKeys": {
559 "mcp_oauth_session_scope_id_id_pk": {
560 "name": "mcp_oauth_session_scope_id_id_pk",
561 "columns": [
562 "scope_id",
563 "id"
564 ]
565 }
566 },
567 "uniqueConstraints": {},
568 "policies": {},
569 "checkConstraints": {},
570 "isRLSEnabled": false
571 },
572 "public.mcp_source": {
573 "name": "mcp_source",
574 "schema": "",
575 "columns": {
576 "id": {
577 "name": "id",
578 "type": "text",
579 "primaryKey": false,
580 "notNull": true
581 },
582 "scope_id": {
583 "name": "scope_id",
584 "type": "text",
585 "primaryKey": false,
586 "notNull": true
587 },
588 "name": {
589 "name": "name",
590 "type": "text",
591 "primaryKey": false,
592 "notNull": true
593 },
594 "config": {
595 "name": "config",
596 "type": "jsonb",
597 "primaryKey": false,
598 "notNull": true
599 },
600 "created_at": {
601 "name": "created_at",
602 "type": "timestamp",
603 "primaryKey": false,
604 "notNull": true
605 }
606 },
607 "indexes": {
608 "mcp_source_scope_id_idx": {
609 "name": "mcp_source_scope_id_idx",
610 "columns": [
611 {
612 "expression": "scope_id",
613 "isExpression": false,
614 "asc": true,
615 "nulls": "last"
616 }
617 ],
618 "isUnique": false,
619 "concurrently": false,
620 "method": "btree",
621 "with": {}
622 }
623 },
624 "foreignKeys": {},
625 "compositePrimaryKeys": {
626 "mcp_source_scope_id_id_pk": {
627 "name": "mcp_source_scope_id_id_pk",
628 "columns": [
629 "scope_id",
630 "id"
631 ]
632 }
633 },
634 "uniqueConstraints": {},
635 "policies": {},
636 "checkConstraints": {},
637 "isRLSEnabled": false
638 },
639 "public.openapi_oauth_session": {
640 "name": "openapi_oauth_session",
641 "schema": "",
642 "columns": {
643 "id": {
644 "name": "id",
645 "type": "text",
646 "primaryKey": false,
647 "notNull": true
648 },
649 "scope_id": {
650 "name": "scope_id",
651 "type": "text",
652 "primaryKey": false,
653 "notNull": true
654 },
655 "session": {
656 "name": "session",
657 "type": "jsonb",
658 "primaryKey": false,
659 "notNull": true
660 },
661 "created_at": {
662 "name": "created_at",
663 "type": "timestamp",
664 "primaryKey": false,
665 "notNull": true
666 }
667 },
668 "indexes": {
669 "openapi_oauth_session_scope_id_idx": {
670 "name": "openapi_oauth_session_scope_id_idx",
671 "columns": [
672 {
673 "expression": "scope_id",
674 "isExpression": false,
675 "asc": true,
676 "nulls": "last"
677 }
678 ],
679 "isUnique": false,
680 "concurrently": false,
681 "method": "btree",
682 "with": {}
683 }
684 },
685 "foreignKeys": {},
686 "compositePrimaryKeys": {
687 "openapi_oauth_session_scope_id_id_pk": {
688 "name": "openapi_oauth_session_scope_id_id_pk",
689 "columns": [
690 "scope_id",
691 "id"
692 ]
693 }
694 },
695 "uniqueConstraints": {},
696 "policies": {},
697 "checkConstraints": {},
698 "isRLSEnabled": false
699 },
700 "public.openapi_operation": {
701 "name": "openapi_operation",
702 "schema": "",
703 "columns": {
704 "id": {
705 "name": "id",
706 "type": "text",
707 "primaryKey": false,
708 "notNull": true
709 },
710 "scope_id": {
711 "name": "scope_id",
712 "type": "text",
713 "primaryKey": false,
714 "notNull": true
715 },
716 "source_id": {
717 "name": "source_id",
718 "type": "text",
719 "primaryKey": false,
720 "notNull": true
721 },
722 "binding": {
723 "name": "binding",
724 "type": "jsonb",
725 "primaryKey": false,
726 "notNull": true
727 }
728 },
729 "indexes": {
730 "openapi_operation_scope_id_idx": {
731 "name": "openapi_operation_scope_id_idx",
732 "columns": [
733 {
734 "expression": "scope_id",
735 "isExpression": false,
736 "asc": true,
737 "nulls": "last"
738 }
739 ],
740 "isUnique": false,
741 "concurrently": false,
742 "method": "btree",
743 "with": {}
744 },
745 "openapi_operation_source_id_idx": {
746 "name": "openapi_operation_source_id_idx",
747 "columns": [
748 {
749 "expression": "source_id",
750 "isExpression": false,
751 "asc": true,
752 "nulls": "last"
753 }
754 ],
755 "isUnique": false,
756 "concurrently": false,
757 "method": "btree",
758 "with": {}
759 }
760 },
761 "foreignKeys": {},
762 "compositePrimaryKeys": {
763 "openapi_operation_scope_id_id_pk": {
764 "name": "openapi_operation_scope_id_id_pk",
765 "columns": [
766 "scope_id",
767 "id"
768 ]
769 }
770 },
771 "uniqueConstraints": {},
772 "policies": {},
773 "checkConstraints": {},
774 "isRLSEnabled": false
775 },
776 "public.openapi_source": {
777 "name": "openapi_source",
778 "schema": "",
779 "columns": {
780 "id": {
781 "name": "id",
782 "type": "text",
783 "primaryKey": false,
784 "notNull": true
785 },
786 "scope_id": {
787 "name": "scope_id",
788 "type": "text",
789 "primaryKey": false,
790 "notNull": true
791 },
792 "name": {
793 "name": "name",
794 "type": "text",
795 "primaryKey": false,
796 "notNull": true
797 },
798 "spec": {
799 "name": "spec",
800 "type": "text",
801 "primaryKey": false,
802 "notNull": true
803 },
804 "source_url": {
805 "name": "source_url",
806 "type": "text",
807 "primaryKey": false,
808 "notNull": false
809 },
810 "base_url": {
811 "name": "base_url",
812 "type": "text",
813 "primaryKey": false,
814 "notNull": false
815 },
816 "headers": {
817 "name": "headers",
818 "type": "jsonb",
819 "primaryKey": false,
820 "notNull": false
821 },
822 "oauth2": {
823 "name": "oauth2",
824 "type": "jsonb",
825 "primaryKey": false,
826 "notNull": false
827 },
828 "invocation_config": {
829 "name": "invocation_config",
830 "type": "jsonb",
831 "primaryKey": false,
832 "notNull": true
833 }
834 },
835 "indexes": {
836 "openapi_source_scope_id_idx": {
837 "name": "openapi_source_scope_id_idx",
838 "columns": [
839 {
840 "expression": "scope_id",
841 "isExpression": false,
842 "asc": true,
843 "nulls": "last"
844 }
845 ],
846 "isUnique": false,
847 "concurrently": false,
848 "method": "btree",
849 "with": {}
850 }
851 },
852 "foreignKeys": {},
853 "compositePrimaryKeys": {
854 "openapi_source_scope_id_id_pk": {
855 "name": "openapi_source_scope_id_id_pk",
856 "columns": [
857 "scope_id",
858 "id"
859 ]
860 }
861 },
862 "uniqueConstraints": {},
863 "policies": {},
864 "checkConstraints": {},
865 "isRLSEnabled": false
866 },
867 "public.secret": {
868 "name": "secret",
869 "schema": "",
870 "columns": {
871 "id": {
872 "name": "id",
873 "type": "text",
874 "primaryKey": false,
875 "notNull": true
876 },
877 "scope_id": {
878 "name": "scope_id",
879 "type": "text",
880 "primaryKey": false,
881 "notNull": true
882 },
883 "name": {
884 "name": "name",
885 "type": "text",
886 "primaryKey": false,
887 "notNull": true
888 },
889 "provider": {
890 "name": "provider",
891 "type": "text",
892 "primaryKey": false,
893 "notNull": true
894 },
895 "created_at": {
896 "name": "created_at",
897 "type": "timestamp",
898 "primaryKey": false,
899 "notNull": true
900 }
901 },
902 "indexes": {
903 "secret_scope_id_idx": {
904 "name": "secret_scope_id_idx",
905 "columns": [
906 {
907 "expression": "scope_id",
908 "isExpression": false,
909 "asc": true,
910 "nulls": "last"
911 }
912 ],
913 "isUnique": false,
914 "concurrently": false,
915 "method": "btree",
916 "with": {}
917 },
918 "secret_provider_idx": {
919 "name": "secret_provider_idx",
920 "columns": [
921 {
922 "expression": "provider",
923 "isExpression": false,
924 "asc": true,
925 "nulls": "last"
926 }
927 ],
928 "isUnique": false,
929 "concurrently": false,
930 "method": "btree",
931 "with": {}
932 }
933 },
934 "foreignKeys": {},
935 "compositePrimaryKeys": {
936 "secret_scope_id_id_pk": {
937 "name": "secret_scope_id_id_pk",
938 "columns": [
939 "scope_id",
940 "id"
941 ]
942 }
943 },
944 "uniqueConstraints": {},
945 "policies": {},
946 "checkConstraints": {},
947 "isRLSEnabled": false
948 },
949 "public.source": {
950 "name": "source",
951 "schema": "",
952 "columns": {
953 "id": {
954 "name": "id",
955 "type": "text",
956 "primaryKey": false,
957 "notNull": true
958 },
959 "scope_id": {
960 "name": "scope_id",
961 "type": "text",
962 "primaryKey": false,
963 "notNull": true
964 },
965 "plugin_id": {
966 "name": "plugin_id",
967 "type": "text",
968 "primaryKey": false,
969 "notNull": true
970 },
971 "kind": {
972 "name": "kind",
973 "type": "text",
974 "primaryKey": false,
975 "notNull": true
976 },
977 "name": {
978 "name": "name",
979 "type": "text",
980 "primaryKey": false,
981 "notNull": true
982 },
983 "url": {
984 "name": "url",
985 "type": "text",
986 "primaryKey": false,
987 "notNull": false
988 },
989 "can_remove": {
990 "name": "can_remove",
991 "type": "boolean",
992 "primaryKey": false,
993 "notNull": true,
994 "default": true
995 },
996 "can_refresh": {
997 "name": "can_refresh",
998 "type": "boolean",
999 "primaryKey": false,
1000 "notNull": true,
1001 "default": false
1002 },
1003 "can_edit": {
1004 "name": "can_edit",
1005 "type": "boolean",
1006 "primaryKey": false,
1007 "notNull": true,
1008 "default": false
1009 },
1010 "created_at": {
1011 "name": "created_at",
1012 "type": "timestamp",
1013 "primaryKey": false,
1014 "notNull": true
1015 },
1016 "updated_at": {
1017 "name": "updated_at",
1018 "type": "timestamp",
1019 "primaryKey": false,
1020 "notNull": true
1021 }
1022 },
1023 "indexes": {
1024 "source_scope_id_idx": {
1025 "name": "source_scope_id_idx",
1026 "columns": [
1027 {
1028 "expression": "scope_id",
1029 "isExpression": false,
1030 "asc": true,
1031 "nulls": "last"
1032 }
1033 ],
1034 "isUnique": false,
1035 "concurrently": false,
1036 "method": "btree",
1037 "with": {}
1038 },
1039 "source_plugin_id_idx": {
1040 "name": "source_plugin_id_idx",
1041 "columns": [
1042 {
1043 "expression": "plugin_id",
1044 "isExpression": false,
1045 "asc": true,
1046 "nulls": "last"
1047 }
1048 ],
1049 "isUnique": false,
1050 "concurrently": false,
1051 "method": "btree",
1052 "with": {}
1053 }
1054 },
1055 "foreignKeys": {},
1056 "compositePrimaryKeys": {
1057 "source_scope_id_id_pk": {
1058 "name": "source_scope_id_id_pk",
1059 "columns": [
1060 "scope_id",
1061 "id"
1062 ]
1063 }
1064 },
1065 "uniqueConstraints": {},
1066 "policies": {},
1067 "checkConstraints": {},
1068 "isRLSEnabled": false
1069 },
1070 "public.tool": {
1071 "name": "tool",
1072 "schema": "",
1073 "columns": {
1074 "id": {
1075 "name": "id",
1076 "type": "text",
1077 "primaryKey": false,
1078 "notNull": true
1079 },
1080 "scope_id": {
1081 "name": "scope_id",
1082 "type": "text",
1083 "primaryKey": false,
1084 "notNull": true
1085 },
1086 "source_id": {
1087 "name": "source_id",
1088 "type": "text",
1089 "primaryKey": false,
1090 "notNull": true
1091 },
1092 "plugin_id": {
1093 "name": "plugin_id",
1094 "type": "text",
1095 "primaryKey": false,
1096 "notNull": true
1097 },
1098 "name": {
1099 "name": "name",
1100 "type": "text",
1101 "primaryKey": false,
1102 "notNull": true
1103 },
1104 "description": {
1105 "name": "description",
1106 "type": "text",
1107 "primaryKey": false,
1108 "notNull": true
1109 },
1110 "input_schema": {
1111 "name": "input_schema",
1112 "type": "jsonb",
1113 "primaryKey": false,
1114 "notNull": false
1115 },
1116 "output_schema": {
1117 "name": "output_schema",
1118 "type": "jsonb",
1119 "primaryKey": false,
1120 "notNull": false
1121 },
1122 "created_at": {
1123 "name": "created_at",
1124 "type": "timestamp",
1125 "primaryKey": false,
1126 "notNull": true
1127 },
1128 "updated_at": {
1129 "name": "updated_at",
1130 "type": "timestamp",
1131 "primaryKey": false,
1132 "notNull": true
1133 }
1134 },
1135 "indexes": {
1136 "tool_scope_id_idx": {
1137 "name": "tool_scope_id_idx",
1138 "columns": [
1139 {
1140 "expression": "scope_id",
1141 "isExpression": false,
1142 "asc": true,
1143 "nulls": "last"
1144 }
1145 ],
1146 "isUnique": false,
1147 "concurrently": false,
1148 "method": "btree",
1149 "with": {}
1150 },
1151 "tool_source_id_idx": {
1152 "name": "tool_source_id_idx",
1153 "columns": [
1154 {
1155 "expression": "source_id",
1156 "isExpression": false,
1157 "asc": true,
1158 "nulls": "last"
1159 }
1160 ],
1161 "isUnique": false,
1162 "concurrently": false,
1163 "method": "btree",
1164 "with": {}
1165 },
1166 "tool_plugin_id_idx": {
1167 "name": "tool_plugin_id_idx",
1168 "columns": [
1169 {
1170 "expression": "plugin_id",
1171 "isExpression": false,
1172 "asc": true,
1173 "nulls": "last"
1174 }
1175 ],
1176 "isUnique": false,
1177 "concurrently": false,
1178 "method": "btree",
1179 "with": {}
1180 }
1181 },
1182 "foreignKeys": {},
1183 "compositePrimaryKeys": {
1184 "tool_scope_id_id_pk": {
1185 "name": "tool_scope_id_id_pk",
1186 "columns": [
1187 "scope_id",
1188 "id"
1189 ]
1190 }
1191 },
1192 "uniqueConstraints": {},
1193 "policies": {},
1194 "checkConstraints": {},
1195 "isRLSEnabled": false
1196 },
1197 "public.workos_vault_metadata": {
1198 "name": "workos_vault_metadata",
1199 "schema": "",
1200 "columns": {
1201 "id": {
1202 "name": "id",
1203 "type": "text",
1204 "primaryKey": false,
1205 "notNull": true
1206 },
1207 "scope_id": {
1208 "name": "scope_id",
1209 "type": "text",
1210 "primaryKey": false,
1211 "notNull": true
1212 },
1213 "name": {
1214 "name": "name",
1215 "type": "text",
1216 "primaryKey": false,
1217 "notNull": true
1218 },
1219 "purpose": {
1220 "name": "purpose",
1221 "type": "text",
1222 "primaryKey": false,
1223 "notNull": false
1224 },
1225 "created_at": {
1226 "name": "created_at",
1227 "type": "timestamp",
1228 "primaryKey": false,
1229 "notNull": true
1230 }
1231 },
1232 "indexes": {
1233 "workos_vault_metadata_scope_id_idx": {
1234 "name": "workos_vault_metadata_scope_id_idx",
1235 "columns": [
1236 {
1237 "expression": "scope_id",
1238 "isExpression": false,
1239 "asc": true,
1240 "nulls": "last"
1241 }
1242 ],
1243 "isUnique": false,
1244 "concurrently": false,
1245 "method": "btree",
1246 "with": {}
1247 }
1248 },
1249 "foreignKeys": {},
1250 "compositePrimaryKeys": {
1251 "workos_vault_metadata_scope_id_id_pk": {
1252 "name": "workos_vault_metadata_scope_id_id_pk",
1253 "columns": [
1254 "scope_id",
1255 "id"
1256 ]
1257 }
1258 },
1259 "uniqueConstraints": {},
1260 "policies": {},
1261 "checkConstraints": {},
1262 "isRLSEnabled": false
1263 }
1264 },
1265 "enums": {},
1266 "schemas": {},
1267 "sequences": {},
1268 "roles": {},
1269 "policies": {},
1270 "views": {},
1271 "_meta": {
1272 "columns": {},
1273 "schemas": {},
1274 "tables": {}
1275 }
1276}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -8,6 +8,13 @@ · 7 unmodified lines
8 "when": 1776367874348,8 "when": 1776367874348,
9 "tag": "0000_lame_rage",9 "tag": "0000_lame_rage",
10 "breakpoints": true10 "breakpoints": true
11 },
12 {
13 "idx": 1,
14 "version": "7",
15 "when": 1776678968180,
16 "tag": "0001_harsh_meltdown",
17 "breakpoints": true
11 }18 }
12 ]19 ]
13}20}
apps/cloud/src/services/executor-schema.ts+2−0
@@ -68,6 +68,7 @@ export const openapi_source = pgTable("openapi_source", { · 67 unmodified lines
68 scope_id: text('scope_id').notNull(),68 scope_id: text('scope_id').notNull(),
69 name: text('name').notNull(),69 name: text('name').notNull(),
70 spec: text('spec').notNull(),70 spec: text('spec').notNull(),
71 source_url: text('source_url'),
71 base_url: text('base_url'),72 base_url: text('base_url'),
72 headers: jsonb('headers'),73 headers: jsonb('headers'),
73 oauth2: jsonb('oauth2'),74 oauth2: jsonb('oauth2'),
@@ -176,3 +177,4 @@ export const blob = pgTable("blob", { · 102 unmodified lines
176 primaryKey({ columns: [table.namespace, table.key] }),177 primaryKey({ columns: [table.namespace, table.key] }),
177]);178]);
178 179
180
apps/cloud/src/services/sources-refresh.node.test.ts+177−0
@@ -0,0 +1,177 @@
1// Refresh endpoint — covers `sources.refresh(id)` for an OpenAPI
2// source added from a URL. Stands up a local HTTP server that serves
3// one of two spec versions (swappable mid-test) so we can verify the
4// refresh path re-fetches from the stored origin and replaces the
5// operation set. Raw-text sources assert the no-op branch.
6
7import { describe, expect, it } from "@effect/vitest";
8import { Effect } from "effect";
9import http from "node:http";
10import { AddressInfo } from "node:net";
11
12import { ScopeId } from "@executor/sdk";
13
14import { asOrg } from "./__test-harness__/api-harness";
15
16const specV1 = JSON.stringify({
17 openapi: "3.0.0",
18 info: { title: "Refresh Fixture", version: "1.0.0" },
19 paths: {
20 "/ping": {
21 get: {
22 operationId: "ping",
23 summary: "ping",
24 responses: { "200": { description: "ok" } },
25 },
26 },
27 },
28});
29
30const specV2 = JSON.stringify({
31 openapi: "3.0.0",
32 info: { title: "Refresh Fixture", version: "2.0.0" },
33 paths: {
34 "/ping": {
35 get: {
36 operationId: "ping",
37 summary: "ping",
38 responses: { "200": { description: "ok" } },
39 },
40 },
41 "/pong": {
42 get: {
43 operationId: "pong",
44 summary: "pong",
45 responses: { "200": { description: "ok" } },
46 },
47 },
48 },
49});
50
51// Mutable ref: tests flip `current` between v1 and v2 around the
52// refresh call. Using a single server keeps the URL stable across
53// both addSpec and refresh — the plugin persists the original URL,
54// so the second fetch goes back to the same endpoint.
55const serveMutableSpec = () => {
56 const state = { current: specV1, requests: 0 };
57 const server = http.createServer((req, res) => {
58 state.requests++;
59 res.writeHead(200, { "content-type": "application/json" });
60 res.end(state.current);
61 });
62 return new Promise<{
63 baseUrl: string;
64 setSpec: (s: string) => void;
65 requestCount: () => number;
66 close: () => Promise<void>;
67 }>((resolve) => {
68 server.listen(0, "127.0.0.1", () => {
69 const { port } = server.address() as AddressInfo;
70 resolve({
71 baseUrl: `http://127.0.0.1:${port}`,
72 setSpec: (s) => {
73 state.current = s;
74 },
75 requestCount: () => state.requests,
76 close: () =>
77 new Promise((r) => {
78 server.close(() => r());
79 }),
80 });
81 });
82 });
83};
84
85describe("sources.refresh (HTTP)", () => {
86 it.effect("addSpec from URL → canRefresh:true; refresh re-fetches and updates tools", () =>
87 Effect.gen(function* () {
88 const server = yield* Effect.promise(() => serveMutableSpec());
89 try {
90 const org = `org_${crypto.randomUUID()}`;
91 const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`;
92
93 yield* asOrg(org, (client) =>
94 client.openapi.addSpec({
95 path: { scopeId: ScopeId.make(org) },
96 payload: { spec: `${server.baseUrl}/spec.json`, namespace },
97 }),
98 );
99
100 const before = yield* asOrg(org, (client) =>
101 client.sources.list({ path: { scopeId: ScopeId.make(org) } }),
102 );
103 const beforeSource = before.find((s) => s.id === namespace);
104 expect(beforeSource?.canRefresh).toBe(true);
105
106 const fetchedBefore = yield* asOrg(org, (client) =>
107 client.openapi.getSource({
108 path: { scopeId: ScopeId.make(org), namespace },
109 }),
110 );
111 expect(fetchedBefore?.config.sourceUrl).toBe(`${server.baseUrl}/spec.json`);
112
113 const beforeTools = yield* asOrg(org, (client) =>
114 client.sources.tools({
115 path: { scopeId: ScopeId.make(org), sourceId: namespace },
116 }),
117 );
118 expect(beforeTools.length).toBe(1);
119 expect(beforeTools.some((t) => t.name.startsWith("ping"))).toBe(true);
120 expect(beforeTools.some((t) => t.name.startsWith("pong"))).toBe(false);
121
122 // Flip the remote to v2 (adds `pong`) and trigger refresh.
123 server.setSpec(specV2);
124 const requestsBefore = server.requestCount();
125
126 const refreshResult = yield* asOrg(org, (client) =>
127 client.sources.refresh({
128 path: { scopeId: ScopeId.make(org), sourceId: namespace },
129 }),
130 );
131 expect(refreshResult.refreshed).toBe(true);
132 expect(server.requestCount()).toBeGreaterThan(requestsBefore);
133
134 const afterTools = yield* asOrg(org, (client) =>
135 client.sources.tools({
136 path: { scopeId: ScopeId.make(org), sourceId: namespace },
137 }),
138 );
139 expect(afterTools.length).toBe(2);
140 expect(afterTools.some((t) => t.name.startsWith("ping"))).toBe(true);
141 expect(afterTools.some((t) => t.name.startsWith("pong"))).toBe(true);
142 } finally {
143 yield* Effect.promise(() => server.close());
144 }
145 }),
146 );
147
148 it.effect("addSpec from raw text → canRefresh:false; refresh is a no-op", () =>
149 Effect.gen(function* () {
150 const org = `org_${crypto.randomUUID()}`;
151 const namespace = `ns_${crypto.randomUUID().replace(/-/g, "_")}`;
152
153 yield* asOrg(org, (client) =>
154 client.openapi.addSpec({
155 path: { scopeId: ScopeId.make(org) },
156 payload: { spec: specV1, namespace },
157 }),
158 );
159
160 const sources = yield* asOrg(org, (client) =>
161 client.sources.list({ path: { scopeId: ScopeId.make(org) } }),
162 );
163 const row = sources.find((s) => s.id === namespace);
164 expect(row?.canRefresh).toBe(false);
165
166 // Raw-text sources reach the plugin with no stored URL and
167 // silently no-op — UI gates the action on canRefresh, but the
168 // server should not 500 if a caller slips through.
169 const result = yield* asOrg(org, (client) =>
170 client.sources.refresh({
171 path: { scopeId: ScopeId.make(org), sourceId: namespace },
172 }),
173 );
174 expect(result.refreshed).toBe(true);
175 }),
176 );
177});
packages/plugins/openapi/src/sdk/plugin.ts+161−83
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18 SetSecretInput,18 SetSecretInput,
19 SourceDetectionResult,19 SourceDetectionResult,
20 definePlugin,20 definePlugin,
21 type PluginCtx,
21 type StorageFailure,22 type StorageFailure,
22 type ToolAnnotations,23 type ToolAnnotations,
23 type ToolRow,24 type ToolRow,
@@ -270,10 +271,154 @@ const toOpenApiSourceConfig = ( · 246 unmodified lines
270 headers: headersToConfigValues(config.headers),271 headers: headersToConfigValues(config.headers),
271});272});
272 273
274const isHttpUrl = (s: string): boolean =>
275 s.startsWith("http://") || s.startsWith("https://");
276
273export const openApiPlugin = definePlugin(277export const openApiPlugin = definePlugin(
274 (options?: OpenApiPluginOptions) => {278 (options?: OpenApiPluginOptions) => {
275 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;279 const httpClientLayer = options?.httpClientLayer ?? FetchHttpClient.layer;
276 280
281 type RebuildInput = {
282 readonly specText: string;
283 readonly scope: string;
284 readonly sourceUrl?: string;
285 readonly name?: string;
286 readonly baseUrl?: string;
287 readonly namespace?: string;
288 readonly headers?: Record<string, HeaderValue>;
289 readonly oauth2?: OAuth2Auth;
290 };
291
292 // ctx comes from the plugin runtime — the same instance is passed to
293 // `extension(ctx)` and to every lifecycle hook (`refreshSource`, etc.),
294 // so helpers parameterised on ctx can be called from either surface.
295 const rebuildSource = (
296 ctx: PluginCtx<OpenapiStore>,
297 input: RebuildInput,
298 ) =>
299 Effect.gen(function* () {
300 const doc = yield* parse(input.specText);
301 const result = yield* extract(doc);
302
303 const namespace =
304 input.namespace ??
305 Option.getOrElse(result.title, () => "api")
306 .toLowerCase()
307 .replace(/[^a-z0-9]+/g, "_");
308
309 const hoistedDefs: Record<string, unknown> = {};
310 if (doc.components?.schemas) {
311 for (const [k, v] of Object.entries(doc.components.schemas)) {
312 hoistedDefs[k] = normalizeOpenApiRefs(v);
313 }
314 }
315
316 const baseUrl = input.baseUrl ?? resolveBaseUrl(result.servers);
317 const oauth2 = input.oauth2 ?? undefined;
318 const invocationConfig = new InvocationConfig({
319 baseUrl,
320 headers: input.headers ?? {},
321 oauth2: oauth2 ? Option.some(oauth2) : Option.none(),
322 });
323
324 const definitions = compileToolDefinitions(result.operations);
325 const sourceName =
326 input.name ?? Option.getOrElse(result.title, () => namespace);
327
328 const sourceConfig: SourceConfig = {
329 spec: input.specText,
330 sourceUrl: input.sourceUrl,
331 baseUrl: input.baseUrl,
332 namespace: input.namespace,
333 headers: input.headers,
334 oauth2,
335 };
336
337 const storedSource: StoredSource = {
338 namespace,
339 scope: input.scope,
340 name: sourceName,
341 config: sourceConfig,
342 invocationConfig,
343 };
344
345 const storedOps: StoredOperation[] = definitions.map((def) => ({
346 toolId: `${namespace}.${def.toolPath}`,
347 sourceId: namespace,
348 binding: toBinding(def),
349 }));
350
351 yield* ctx.transaction(
352 Effect.gen(function* () {
353 yield* ctx.storage.upsertSource(storedSource, storedOps);
354
355 yield* ctx.core.sources.register({
356 id: namespace,
357 scope: input.scope,
358 kind: "openapi",
359 name: sourceName,
360 url: baseUrl || undefined,
361 canRemove: true,
362 // `canRefresh` reflects whether we still know the
363 // origin URL — sources added from raw spec text have
364 // nothing to re-fetch, so refresh stays disabled.
365 canRefresh: input.sourceUrl != null,
366 canEdit: true,
367 tools: definitions.map((def) => ({
368 name: def.toolPath,
369 description: descriptionFor(def),
370 inputSchema: normalizeOpenApiRefs(
371 Option.getOrUndefined(def.operation.inputSchema),
372 ),
373 outputSchema: normalizeOpenApiRefs(
374 Option.getOrUndefined(def.operation.outputSchema),
375 ),
376 })),
377 });
378
379 if (Object.keys(hoistedDefs).length > 0) {
380 yield* ctx.core.definitions.register({
381 sourceId: namespace,
382 scope: input.scope,
383 definitions: hoistedDefs,
384 });
385 }
386 }),
387 );
388
389 return { sourceId: namespace, toolCount: definitions.length };
390 });
391
392 // No-op for missing sources and for sources added from raw spec
393 // text (no URL to re-fetch from). UIs gate the action via
394 // `canRefresh` on the source row; reaching here without a URL
395 // means the caller bypassed that gate, so we stay quiet rather
396 // than surface a 500 through the unwhitelisted error channel.
397 const refreshSourceInternal = (
398 ctx: PluginCtx<OpenapiStore>,
399 sourceId: string,
400 scope: string,
401 ) =>
402 Effect.gen(function* () {
403 const existing = yield* ctx.storage.getSource(sourceId, scope);
404 if (!existing) return;
405 const sourceUrl = existing.config.sourceUrl;
406 if (!sourceUrl) return;
407 const specText = yield* resolveSpecText(sourceUrl).pipe(
408 Effect.provide(httpClientLayer),
409 );
410 yield* rebuildSource(ctx, {
411 specText,
412 scope,
413 sourceUrl,
414 name: existing.name,
415 baseUrl: existing.config.baseUrl,
416 namespace: existing.namespace,
417 headers: existing.config.headers,
418 oauth2: existing.config.oauth2,
419 });
420 });
421
277 return {422 return {
278 id: "openapi" as const,423 id: "openapi" as const,
279 schema: openapiSchema,424 schema: openapiSchema,
@@ -310,92 +455,16 @@ export const openApiPlugin = definePlugin( · 30 unmodified lines
310 const specText = yield* resolveSpecText(config.spec).pipe(455 const specText = yield* resolveSpecText(config.spec).pipe(
311 Effect.provide(httpClientLayer),456 Effect.provide(httpClientLayer),
312 );457 );
313 const doc = yield* parse(specText);458 return yield* rebuildSource(ctx, {
314 const result = yield* extract(doc);459 specText,
315 460 scope: config.scope,
316 const namespace =461 sourceUrl: isHttpUrl(config.spec) ? config.spec : undefined,
317 config.namespace ??462 name: config.name,
318 Option.getOrElse(result.title, () => "api")
319 .toLowerCase()
320 .replace(/[^a-z0-9]+/g, "_");
321
322 const hoistedDefs: Record<string, unknown> = {};
323 if (doc.components?.schemas) {
324 for (const [k, v] of Object.entries(doc.components.schemas)) {
325 hoistedDefs[k] = normalizeOpenApiRefs(v);
326 }
327 }
328
329 const baseUrl = config.baseUrl ?? resolveBaseUrl(result.servers);
330 const oauth2 = config.oauth2 ?? undefined;
331 const invocationConfig = new InvocationConfig({
332 baseUrl,
333 headers: config.headers ?? {},
334 oauth2: oauth2 ? Option.some(oauth2) : Option.none(),
335 });
336
337 const definitions = compileToolDefinitions(result.operations);
338 const sourceName =
339 config.name ?? Option.getOrElse(result.title, () => namespace);
340
341 const sourceConfig: SourceConfig = {
342 spec: config.spec,
343 baseUrl: config.baseUrl,463 baseUrl: config.baseUrl,
344 namespace: config.namespace,464 namespace: config.namespace,
345 headers: config.headers,465 headers: config.headers,
346 oauth2,466 oauth2: config.oauth2,
347 };467 });
348
349 const storedSource: StoredSource = {
350 namespace,
351 scope: config.scope,
352 name: sourceName,
353 config: sourceConfig,
354 invocationConfig,
355 };
356
357 const storedOps: StoredOperation[] = definitions.map((def) => ({
358 toolId: `${namespace}.${def.toolPath}`,
359 sourceId: namespace,
360 binding: toBinding(def),
361 }));
362
363 yield* ctx.transaction(
364 Effect.gen(function* () {
365 yield* ctx.storage.upsertSource(storedSource, storedOps);
366
367 yield* ctx.core.sources.register({
368 id: namespace,
369 scope: config.scope,
370 kind: "openapi",
371 name: sourceName,
372 url: baseUrl || undefined,
373 canRemove: true,
374 canRefresh: false,
375 canEdit: true,
376 tools: definitions.map((def) => ({
377 name: def.toolPath,
378 description: descriptionFor(def),
379 inputSchema: normalizeOpenApiRefs(
380 Option.getOrUndefined(def.operation.inputSchema),
381 ),
382 outputSchema: normalizeOpenApiRefs(
383 Option.getOrUndefined(def.operation.outputSchema),
384 ),
385 })),
386 });
387
388 if (Object.keys(hoistedDefs).length > 0) {
389 yield* ctx.core.definitions.register({
390 sourceId: namespace,
391 scope: config.scope,
392 definitions: hoistedDefs,
393 });
394 }
395 }),
396 );
397
398 return { sourceId: namespace, toolCount: definitions.length };
399 });468 });
400 469
401 const configFile = options?.configFile;470 const configFile = options?.configFile;
@@ -802,6 +871,15 @@ export const openApiPlugin = definePlugin( · 400 unmodified lines
802 removeSource: ({ ctx, sourceId, scope }) =>871 removeSource: ({ ctx, sourceId, scope }) =>
803 ctx.storage.removeSource(sourceId, scope),872 ctx.storage.removeSource(sourceId, scope),
804 873
874 // Re-fetch the spec from its origin URL (captured at addSpec time)
875 // and replay the same parse → extract → upsertSource → register
876 // path used by addSpec. Sources without a stored URL surface a
877 // typed `OpenApiParseError` — the executor only dispatches refresh
878 // when `canRefresh: true`, so a raw-text source reaching here
879 // means stale UI state, which is worth surfacing to the caller.
880 refreshSource: ({ ctx, sourceId, scope }) =>
881 refreshSourceInternal(ctx, sourceId, scope),
882
805 detect: ({ url }) =>883 detect: ({ url }) =>
806 Effect.gen(function* () {884 Effect.gen(function* () {
807 const trimmed = url.trim();885 const trimmed = url.trim();
packages/plugins/openapi/src/sdk/store.ts+11−0
@@ -29,6 +29,11 @@ export const openapiSchema = defineSchema({ · 28 unmodified lines
29 scope_id: { type: "string", required: true, index: true },29 scope_id: { type: "string", required: true, index: true },
30 name: { type: "string", required: true },30 name: { type: "string", required: true },
31 spec: { type: "string", required: true },31 spec: { type: "string", required: true },
32 // Origin URL the spec was fetched from. Set when `addSpec` was
33 // invoked with an http(s) URL; null when the caller passed raw
34 // spec text. Drives `canRefresh` on the core source row and
35 // is the address re-fetched on `refreshSource`.
36 source_url: { type: "string", required: false },
32 base_url: { type: "string", required: false },37 base_url: { type: "string", required: false },
33 headers: { type: "json", required: false },38 headers: { type: "json", required: false },
34 oauth2: { type: "json", required: false },39 oauth2: { type: "json", required: false },
@@ -62,6 +67,9 @@ export type OpenapiSchema = typeof openapiSchema; · 27 unmodified lines
62 67
63export interface SourceConfig {68export interface SourceConfig {
64 readonly spec: string;69 readonly spec: string;
70 /** Origin URL when the spec was fetched from http(s). Absent for
71 * raw-text adds. Persisted so `refreshSource` can re-fetch. */
72 readonly sourceUrl?: string;
65 readonly baseUrl?: string;73 readonly baseUrl?: string;
66 readonly namespace?: string;74 readonly namespace?: string;
67 readonly headers?: Record<string, HeaderValue>;75 readonly headers?: Record<string, HeaderValue>;
@@ -92,6 +100,7 @@ export class StoredSourceSchema extends Schema.Class<StoredSourceSchema>( · 24 unmodified lines
92 name: Schema.String,100 name: Schema.String,
93 config: Schema.Struct({101 config: Schema.Struct({
94 spec: Schema.String,102 spec: Schema.String,
103 sourceUrl: Schema.optional(Schema.String),
95 baseUrl: Schema.optional(Schema.String),104 baseUrl: Schema.optional(Schema.String),
96 namespace: Schema.optional(Schema.String),105 namespace: Schema.optional(Schema.String),
97 headers: Schema.optional(106 headers: Schema.optional(
@@ -239,6 +248,7 @@ export const makeDefaultOpenapiStore = ({ · 141 unmodified lines
239 name: row.name as string,248 name: row.name as string,
240 config: {249 config: {
241 spec: row.spec as string,250 spec: row.spec as string,
251 sourceUrl: (row.source_url as string | null | undefined) ?? undefined,
242 baseUrl: (row.base_url as string | null | undefined) ?? undefined,252 baseUrl: (row.base_url as string | null | undefined) ?? undefined,
243 headers,253 headers,
244 oauth2,254 oauth2,
@@ -284,6 +294,7 @@ export const makeDefaultOpenapiStore = ({ · 39 unmodified lines
284 scope_id: input.scope,294 scope_id: input.scope,
285 name: input.name,295 name: input.name,
286 spec: input.config.spec,296 spec: input.config.spec,
297 source_url: input.config.sourceUrl ?? undefined,
287 base_url: input.config.baseUrl ?? undefined,298 base_url: input.config.baseUrl ?? undefined,
288 headers: (input.config.headers ?? {}) as unknown as Record<string, unknown>,299 headers: (input.config.headers ?? {}) as unknown as Record<string, unknown>,
289 oauth2: input.config.oauth2300 oauth2: input.config.oauth2