Rename useConnectMcpSource to useStartSourceOAuth and add new hooks: useDiscoverSource, useConnectSource, useRefreshSecrets. Enhance the mutation system with OptimisticMutationResult type and add dev-mode error logging for failed API calls.
Unified · Split
| @@ -3,20 +3,25 @@ import type * as Registry from "@effect-atom/atom/Registry"; · 2 unmodified lines | |||
| 3 | import { RegistryContext, RegistryProvider, useAtomValue } from "@effect-atom/atom-react"; | 3 | import { RegistryContext, RegistryProvider, useAtomValue } from "@effect-atom/atom-react"; |
| 4 | import { createControlPlaneClient } from "@executor-v3/control-plane/client"; | 4 | import { createControlPlaneClient } from "@executor-v3/control-plane/client"; |
| 5 | import type { | 5 | import type { |
| 6 | ConnectMcpSourcePayload, | 6 | CompleteSourceOAuthResult, |
| 7 | ConnectMcpSourceResult, | 7 | ConnectSourcePayload, |
| 8 | ConnectSourceResult, | ||
| 8 | ControlPlaneClient, | 9 | ControlPlaneClient, |
| 9 | CreateSecretPayload, | 10 | CreateSecretPayload, |
| 10 | CreateSecretResult, | 11 | CreateSecretResult, |
| 11 | CreateSourcePayload, | 12 | CreateSourcePayload, |
| 12 | DeleteSecretResult, | 13 | DeleteSecretResult, |
| 14 | DiscoverSourcePayload, | ||
| 13 | InstanceConfig, | 15 | InstanceConfig, |
| 14 | LocalInstallation, | 16 | LocalInstallation, |
| 15 | SecretListItem, | 17 | SecretListItem, |
| 16 | Source, | 18 | Source, |
| 19 | SourceDiscoveryResult, | ||
| 17 | SourceInspection, | 20 | SourceInspection, |
| 18 | SourceInspectionDiscoverResult, | 21 | SourceInspectionDiscoverResult, |
| 19 | SourceInspectionToolDetail, | 22 | SourceInspectionToolDetail, |
| 23 | StartSourceOAuthPayload, | ||
| 24 | StartSourceOAuthResult, | ||
| 20 | UpdateSecretPayload, | 25 | UpdateSecretPayload, |
| 21 | UpdateSecretResult, | 26 | UpdateSecretResult, |
| 22 | UpdateSourcePayload, | 27 | UpdateSourcePayload, |
| @@ -87,9 +92,25 @@ type MutationExecutionContext = { · 64 unmodified lines | |||
| 87 | invalidateQueries: (target?: InvalidationTarget) => void; | 92 | invalidateQueries: (target?: InvalidationTarget) => void; |
| 88 | }; | 93 | }; |
| 89 | 94 | ||
| 90 | type MutationOptions<TInput, TOutput> = { | 95 | type OptimisticMutationResult<T> = |
| 91 | optimisticUpdate?: (context: MutationExecutionContext, payload: TInput) => void | (() => void); | 96 | | void |
| 92 | onSuccess?: (context: MutationExecutionContext, payload: TInput, data: TOutput) => void; | 97 | | (() => void) |
| 98 | | { | ||
| 99 | rollback?: () => void; | ||
| 100 | value?: T; | ||
| 101 | }; | ||
| 102 | |||
| 103 | type MutationOptions<TInput, TOutput, TOptimistic = never> = { | ||
| 104 | optimisticUpdate?: ( | ||
| 105 | context: MutationExecutionContext, | ||
| 106 | payload: TInput, | ||
| 107 | ) => OptimisticMutationResult<TOptimistic>; | ||
| 108 | onSuccess?: ( | ||
| 109 | context: MutationExecutionContext, | ||
| 110 | payload: TInput, | ||
| 111 | data: TOutput, | ||
| 112 | optimisticValue: TOptimistic | undefined, | ||
| 113 | ) => void; | ||
| 93 | }; | 114 | }; |
| 94 | 115 | ||
| 95 | type InternalNode<A> = { | 116 | type InternalNode<A> = { |
| @@ -146,6 +167,37 @@ const causeMessage = (cause: Cause.Cause<unknown>): Error => · 50 unmodified lines | |||
| 146 | const toError = (cause: unknown): Error => | 167 | const toError = (cause: unknown): Error => |
| 147 | cause instanceof Error ? cause : new Error(String(cause)); | 168 | cause instanceof Error ? cause : new Error(String(cause)); |
| 148 | 169 | ||
| 170 | const shouldLogExecutorDevErrors = (): boolean => { | ||
| 171 | if (typeof window === "undefined") { | ||
| 172 | return false; | ||
| 173 | } | ||
| 174 | |||
| 175 | const hostname = window.location.hostname; | ||
| 176 | return hostname === "localhost" || hostname === "127.0.0.1"; | ||
| 177 | }; | ||
| 178 | |||
| 179 | const describeExecutorDevError = (cause: unknown): Record<string, unknown> => { | ||
| 180 | if (cause instanceof Error) { | ||
| 181 | return { | ||
| 182 | name: cause.name, | ||
| 183 | message: cause.message, | ||
| 184 | stack: cause.stack, | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | |||
| 188 | return { | ||
| 189 | message: String(cause), | ||
| 190 | }; | ||
| 191 | }; | ||
| 192 | |||
| 193 | const logExecutorDevError = (label: string, details: Record<string, unknown>): void => { | ||
| 194 | if (!shouldLogExecutorDevErrors()) { | ||
| 195 | return; | ||
| 196 | } | ||
| 197 | |||
| 198 | console.error(`[executor react] ${label}`, details); | ||
| 199 | }; | ||
| 200 | |||
| 149 | const runControlPlane = <A>(input: { | 201 | const runControlPlane = <A>(input: { |
| 150 | accountId?: string; | 202 | accountId?: string; |
| 151 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; | 203 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; |
| @@ -157,7 +209,15 @@ const runControlPlane = <A>(input: { · 5 unmodified lines | |||
| 157 | baseUrl: apiBaseUrl, | 209 | baseUrl: apiBaseUrl, |
| 158 | ...(accountId !== undefined ? { accountId } : {}), | 210 | ...(accountId !== undefined ? { accountId } : {}), |
| 159 | }).pipe(Effect.flatMap(input.execute)), | 211 | }).pipe(Effect.flatMap(input.execute)), |
| 160 | ); | 212 | ).catch((cause) => { |
| 213 | logExecutorDevError("control-plane request failed", { | ||
| 214 | baseUrl: apiBaseUrl, | ||
| 215 | accountId, | ||
| 216 | error: describeExecutorDevError(cause), | ||
| 217 | cause, | ||
| 218 | }); | ||
| 219 | throw cause; | ||
| 220 | }); | ||
| 161 | }; | 221 | }; |
| 162 | 222 | ||
| 163 | const controlPlaneRequest = <A>(input: { | 223 | const controlPlaneRequest = <A>(input: { |
| @@ -576,13 +636,13 @@ const applyUpdatePayloadToSource = (source: Source, payload: UpdateSourcePayload · 412 unmodified lines | |||
| 576 | updatedAt: Date.now(), | 636 | updatedAt: Date.now(), |
| 577 | }); | 637 | }); |
| 578 | 638 | ||
| 579 | const useSourceMutation = <TInput, TOutput>( | 639 | const useSourceMutation = <TInput, TOutput, TOptimistic = never>( |
| 580 | execute: (input: { | 640 | execute: (input: { |
| 581 | workspaceId: Source["workspaceId"]; | 641 | workspaceId: Source["workspaceId"]; |
| 582 | accountId: string; | 642 | accountId: string; |
| 583 | payload: TInput; | 643 | payload: TInput; |
| 584 | }) => Promise<TOutput>, | 644 | }) => Promise<TOutput>, |
| 585 | options?: MutationOptions<TInput, TOutput>, | 645 | options?: MutationOptions<TInput, TOutput, TOptimistic>, |
| 586 | ) => { | 646 | ) => { |
| 587 | const workspace = useWorkspaceRequestContext(); | 647 | const workspace = useWorkspaceRequestContext(); |
| 588 | const { registry, invalidateQueries } = useExecutorQueryContext(); | 648 | const { registry, invalidateQueries } = useExecutorQueryContext(); |
| @@ -612,7 +672,13 @@ const useSourceMutation = <TInput, TOutput>( · 23 unmodified lines | |||
| 612 | error: null, | 672 | error: null, |
| 613 | })); | 673 | })); |
| 614 | 674 | ||
| 615 | const rollback = options?.optimisticUpdate?.(executionContext, payload); | 675 | const optimistic = options?.optimisticUpdate?.(executionContext, payload); |
| 676 | const rollback = typeof optimistic === "function" | ||
| 677 | ? optimistic | ||
| 678 | : optimistic?.rollback; | ||
| 679 | const optimisticValue = typeof optimistic === "function" | ||
| 680 | ? undefined | ||
| 681 | : optimistic?.value; | ||
| 616 | 682 | ||
| 617 | try { | 683 | try { |
| 618 | const data = await execute({ | 684 | const data = await execute({ |
| @@ -620,11 +686,18 @@ const useSourceMutation = <TInput, TOutput>( · 1 unmodified line | |||
| 620 | accountId: workspace.accountId, | 686 | accountId: workspace.accountId, |
| 621 | payload, | 687 | payload, |
| 622 | }); | 688 | }); |
| 623 | options?.onSuccess?.(executionContext, payload, data); | 689 | options?.onSuccess?.(executionContext, payload, data, optimisticValue); |
| 624 | setState({ status: "success", data, error: null }); | 690 | setState({ status: "success", data, error: null }); |
| 625 | return data; | 691 | return data; |
| 626 | } catch (cause) { | 692 | } catch (cause) { |
| 627 | rollback?.(); | 693 | rollback?.(); |
| 694 | logExecutorDevError("source mutation failed", { | ||
| 695 | workspaceId: workspace.workspaceId, | ||
| 696 | accountId: workspace.accountId, | ||
| 697 | payload, | ||
| 698 | error: describeExecutorDevError(cause), | ||
| 699 | cause, | ||
| 700 | }); | ||
| 628 | const error = cause instanceof Error ? cause : new Error(String(cause)); | 701 | const error = cause instanceof Error ? cause : new Error(String(cause)); |
| 629 | setState({ status: "error", data: null, error }); | 702 | setState({ status: "error", data: null, error }); |
| 630 | throw error; | 703 | throw error; |
| @@ -895,7 +968,7 @@ export const usePrefetchToolDetail = () => { · 264 unmodified lines | |||
| 895 | }; | 968 | }; |
| 896 | 969 | ||
| 897 | export const useCreateSource = () => | 970 | export const useCreateSource = () => |
| 898 | useSourceMutation<CreateSourcePayload, Source>( | 971 | useSourceMutation<CreateSourcePayload, Source, Source>( |
| 899 | React.useCallback( | 972 | React.useCallback( |
| 900 | ({ workspaceId, accountId, payload }) => | 973 | ({ workspaceId, accountId, payload }) => |
| 901 | runControlPlane({ | 974 | runControlPlane({ |
| @@ -922,15 +995,21 @@ export const useCreateSource = () => · 20 unmodified lines | |||
| 922 | payload, | 995 | payload, |
| 923 | }); | 996 | }); |
| 924 | setCachedAtomValue(context.registry, listAtom, [optimisticSource, ...previousList]); | 997 | setCachedAtomValue(context.registry, listAtom, [optimisticSource, ...previousList]); |
| 925 | return () => { | 998 | return { |
| 926 | setCachedAtomValue(context.registry, listAtom, previousList); | 999 | value: optimisticSource, |
| 1000 | rollback: () => { | ||
| 1001 | setCachedAtomValue(context.registry, listAtom, previousList); | ||
| 1002 | }, | ||
| 927 | }; | 1003 | }; |
| 928 | }, | 1004 | }, |
| 929 | onSuccess: (context, _payload, source) => { | 1005 | onSuccess: (context, _payload, source, optimisticSource) => { |
| 930 | const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | 1006 | const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); |
| 931 | const currentList = getCachedAtomValue(context.registry, listAtom); | 1007 | const currentList = getCachedAtomValue(context.registry, listAtom); |
| 932 | if (currentList !== undefined) { | 1008 | if (currentList !== undefined) { |
| 933 | setCachedAtomValue(context.registry, listAtom, upsertSourceInList(currentList, source)); | 1009 | const withoutOptimistic = optimisticSource |
| 1010 | ? currentList.filter((candidate) => candidate.id !== optimisticSource.id) | ||
| 1011 | : currentList; | ||
| 1012 | setCachedAtomValue(context.registry, listAtom, upsertSourceInList(withoutOptimistic, source)); | ||
| 934 | } | 1013 | } |
| 935 | 1014 | ||
| 936 | setCachedAtomValue( | 1015 | setCachedAtomValue( |
| @@ -1003,13 +1082,13 @@ export const useUpdateSource = () => · 66 unmodified lines | |||
| 1003 | }, | 1082 | }, |
| 1004 | ); | 1083 | ); |
| 1005 | 1084 | ||
| 1006 | export const useConnectMcpSource = () => | 1085 | export const useStartSourceOAuth = () => |
| 1007 | useSourceMutation<ConnectMcpSourcePayload, ConnectMcpSourceResult>( | 1086 | useSourceMutation<StartSourceOAuthPayload, StartSourceOAuthResult>( |
| 1008 | React.useCallback( | 1087 | React.useCallback( |
| 1009 | ({ workspaceId, accountId, payload }) => | 1088 | ({ workspaceId, accountId, payload }) => |
| 1010 | runControlPlane({ | 1089 | runControlPlane({ |
| 1011 | accountId, | 1090 | accountId, |
| 1012 | execute: (client) => client.sources.connectMcp({ | 1091 | execute: (client) => client.oauth.startSourceAuth({ |
| 1013 | path: { | 1092 | path: { |
| 1014 | workspaceId, | 1093 | workspaceId, |
| 1015 | }, | 1094 | }, |
| @@ -1018,27 +1097,6 @@ export const useConnectMcpSource = () => · 2 unmodified lines | |||
| 1018 | }), | 1097 | }), |
| 1019 | [], | 1098 | [], |
| 1020 | ), | 1099 | ), |
| 1021 | { | ||
| 1022 | onSuccess: (context, _payload, result) => { | ||
| 1023 | const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | ||
| 1024 | const currentList = getCachedAtomValue(context.registry, listAtom); | ||
| 1025 | if (currentList !== undefined) { | ||
| 1026 | setCachedAtomValue(context.registry, listAtom, upsertSourceInList(currentList, result.source)); | ||
| 1027 | } | ||
| 1028 | |||
| 1029 | setCachedAtomValue( | ||
| 1030 | context.registry, | ||
| 1031 | sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, result.source.id)), | ||
| 1032 | result.source, | ||
| 1033 | ); | ||
| 1034 | |||
| 1035 | context.invalidateQueries({ | ||
| 1036 | workspaceId: context.workspaceId, | ||
| 1037 | accountId: context.accountId, | ||
| 1038 | sourceId: result.source.id, | ||
| 1039 | }); | ||
| 1040 | }, | ||
| 1041 | }, | ||
| 1042 | ); | 1100 | ); |
| 1043 | 1101 | ||
| 1044 | export const useRemoveSource = () => | 1102 | export const useRemoveSource = () => |
| @@ -1079,20 +1137,76 @@ export const useRemoveSource = () => · 34 unmodified lines | |||
| 1079 | }, | 1137 | }, |
| 1080 | ); | 1138 | ); |
| 1081 | 1139 | ||
| 1140 | export const useDiscoverSource = () => | ||
| 1141 | useSourceMutation<DiscoverSourcePayload, SourceDiscoveryResult>( | ||
| 1142 | React.useCallback( | ||
| 1143 | ({ accountId, payload }) => | ||
| 1144 | runControlPlane({ | ||
| 1145 | accountId, | ||
| 1146 | execute: (client) => client.sources.discover({ | ||
| 1147 | payload, | ||
| 1148 | }), | ||
| 1149 | }), | ||
| 1150 | [], | ||
| 1151 | ), | ||
| 1152 | ); | ||
| 1153 | |||
| 1154 | export const useConnectSource = () => | ||
| 1155 | useSourceMutation<ConnectSourcePayload, ConnectSourceResult, Source>( | ||
| 1156 | React.useCallback( | ||
| 1157 | ({ workspaceId, accountId, payload }) => | ||
| 1158 | runControlPlane({ | ||
| 1159 | accountId, | ||
| 1160 | execute: (client) => client.sources.connect({ | ||
| 1161 | path: { | ||
| 1162 | workspaceId, | ||
| 1163 | }, | ||
| 1164 | payload, | ||
| 1165 | } as any), | ||
| 1166 | }), | ||
| 1167 | [], | ||
| 1168 | ), | ||
| 1169 | { | ||
| 1170 | onSuccess: (context, _payload, result) => { | ||
| 1171 | const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); | ||
| 1172 | const currentList = getCachedAtomValue(context.registry, listAtom); | ||
| 1173 | if (currentList !== undefined) { | ||
| 1174 | setCachedAtomValue(context.registry, listAtom, upsertSourceInList(currentList, result.source)); | ||
| 1175 | } | ||
| 1176 | |||
| 1177 | setCachedAtomValue( | ||
| 1178 | context.registry, | ||
| 1179 | sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, result.source.id)), | ||
| 1180 | result.source, | ||
| 1181 | ); | ||
| 1182 | context.invalidateQueries({ | ||
| 1183 | workspaceId: context.workspaceId, | ||
| 1184 | accountId: context.accountId, | ||
| 1185 | }); | ||
| 1186 | }, | ||
| 1187 | }, | ||
| 1188 | ); | ||
| 1189 | |||
| 1082 | export type { | 1190 | export type { |
| 1083 | ConnectMcpSourcePayload, | 1191 | CompleteSourceOAuthResult, |
| 1084 | ConnectMcpSourceResult, | 1192 | ConnectSourcePayload, |
| 1193 | ConnectSourceResult, | ||
| 1085 | CreateSecretPayload, | 1194 | CreateSecretPayload, |
| 1086 | CreateSecretResult, | 1195 | CreateSecretResult, |
| 1087 | CreateSourcePayload, | 1196 | CreateSourcePayload, |
| 1088 | DeleteSecretResult, | 1197 | DeleteSecretResult, |
| 1198 | DiscoverSourcePayload, | ||
| 1089 | InstanceConfig, | 1199 | InstanceConfig, |
| 1090 | LocalInstallation, | 1200 | LocalInstallation, |
| 1201 | |||
| 1091 | SecretListItem, | 1202 | SecretListItem, |
| 1092 | Source, | 1203 | Source, |
| 1204 | SourceDiscoveryResult, | ||
| 1093 | SourceInspection, | 1205 | SourceInspection, |
| 1094 | SourceInspectionDiscoverResult, | 1206 | SourceInspectionDiscoverResult, |
| 1095 | SourceInspectionToolDetail, | 1207 | SourceInspectionToolDetail, |
| 1208 | StartSourceOAuthPayload, | ||
| 1209 | StartSourceOAuthResult, | ||
| 1096 | UpdateSecretPayload, | 1210 | UpdateSecretPayload, |
| 1097 | UpdateSecretResult, | 1211 | UpdateSecretResult, |
| 1098 | UpdateSourcePayload, | 1212 | UpdateSourcePayload, |