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 | 3 | import { RegistryContext, RegistryProvider, useAtomValue } from "@effect-atom/atom-react"; |
| 4 | 4 | import { createControlPlaneClient } from "@executor-v3/control-plane/client"; |
| 5 | 5 | import type { |
| 6 | − ConnectMcpSourcePayload, | |
| 7 | − ConnectMcpSourceResult, | |
| 6 | + CompleteSourceOAuthResult, | |
| 7 | + ConnectSourcePayload, | |
| 8 | + ConnectSourceResult, | |
| 8 | 9 | ControlPlaneClient, |
| 9 | 10 | CreateSecretPayload, |
| 10 | 11 | CreateSecretResult, |
| 11 | 12 | CreateSourcePayload, |
| 12 | 13 | DeleteSecretResult, |
| 14 | + DiscoverSourcePayload, | |
| 13 | 15 | InstanceConfig, |
| 14 | 16 | LocalInstallation, |
| 15 | 17 | SecretListItem, |
| 16 | 18 | Source, |
| 19 | + SourceDiscoveryResult, | |
| 17 | 20 | SourceInspection, |
| 18 | 21 | SourceInspectionDiscoverResult, |
| 19 | 22 | SourceInspectionToolDetail, |
| 23 | + StartSourceOAuthPayload, | |
| 24 | + StartSourceOAuthResult, | |
| 20 | 25 | UpdateSecretPayload, |
| 21 | 26 | UpdateSecretResult, |
| 22 | 27 | UpdateSourcePayload, |
| @@ -87,9 +92,25 @@ type MutationExecutionContext = { · 64 unmodified lines | ||
| 87 | 92 | invalidateQueries: (target?: InvalidationTarget) => void; |
| 88 | 93 | }; |
| 89 | 94 | |
| 90 | −type MutationOptions<TInput, TOutput> = { | |
| 91 | − optimisticUpdate?: (context: MutationExecutionContext, payload: TInput) => void | (() => void); | |
| 92 | − onSuccess?: (context: MutationExecutionContext, payload: TInput, data: TOutput) => void; | |
| 95 | +type OptimisticMutationResult<T> = | |
| 96 | + | 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 | 116 | type InternalNode<A> = { |
| @@ -146,6 +167,37 @@ const causeMessage = (cause: Cause.Cause<unknown>): Error => · 50 unmodified lines | ||
| 146 | 167 | const toError = (cause: unknown): Error => |
| 147 | 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 | 201 | const runControlPlane = <A>(input: { |
| 150 | 202 | accountId?: string; |
| 151 | 203 | execute: (client: ControlPlaneClient) => Effect.Effect<A, unknown, never>; |
| @@ -157,7 +209,15 @@ const runControlPlane = <A>(input: { · 5 unmodified lines | ||
| 157 | 209 | baseUrl: apiBaseUrl, |
| 158 | 210 | ...(accountId !== undefined ? { accountId } : {}), |
| 159 | 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 | 223 | const controlPlaneRequest = <A>(input: { |
| @@ -576,13 +636,13 @@ const applyUpdatePayloadToSource = (source: Source, payload: UpdateSourcePayload · 412 unmodified lines | ||
| 576 | 636 | updatedAt: Date.now(), |
| 577 | 637 | }); |
| 578 | 638 | |
| 579 | −const useSourceMutation = <TInput, TOutput>( | |
| 639 | +const useSourceMutation = <TInput, TOutput, TOptimistic = never>( | |
| 580 | 640 | execute: (input: { |
| 581 | 641 | workspaceId: Source["workspaceId"]; |
| 582 | 642 | accountId: string; |
| 583 | 643 | payload: TInput; |
| 584 | 644 | }) => Promise<TOutput>, |
| 585 | − options?: MutationOptions<TInput, TOutput>, | |
| 645 | + options?: MutationOptions<TInput, TOutput, TOptimistic>, | |
| 586 | 646 | ) => { |
| 587 | 647 | const workspace = useWorkspaceRequestContext(); |
| 588 | 648 | const { registry, invalidateQueries } = useExecutorQueryContext(); |
| @@ -612,7 +672,13 @@ const useSourceMutation = <TInput, TOutput>( · 23 unmodified lines | ||
| 612 | 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 | 683 | try { |
| 618 | 684 | const data = await execute({ |
| @@ -620,11 +686,18 @@ const useSourceMutation = <TInput, TOutput>( · 1 unmodified line | ||
| 620 | 686 | accountId: workspace.accountId, |
| 621 | 687 | payload, |
| 622 | 688 | }); |
| 623 | − options?.onSuccess?.(executionContext, payload, data); | |
| 689 | + options?.onSuccess?.(executionContext, payload, data, optimisticValue); | |
| 624 | 690 | setState({ status: "success", data, error: null }); |
| 625 | 691 | return data; |
| 626 | 692 | } catch (cause) { |
| 627 | 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 | 701 | const error = cause instanceof Error ? cause : new Error(String(cause)); |
| 629 | 702 | setState({ status: "error", data: null, error }); |
| 630 | 703 | throw error; |
| @@ -895,7 +968,7 @@ export const usePrefetchToolDetail = () => { · 264 unmodified lines | ||
| 895 | 968 | }; |
| 896 | 969 | |
| 897 | 970 | export const useCreateSource = () => |
| 898 | − useSourceMutation<CreateSourcePayload, Source>( | |
| 971 | + useSourceMutation<CreateSourcePayload, Source, Source>( | |
| 899 | 972 | React.useCallback( |
| 900 | 973 | ({ workspaceId, accountId, payload }) => |
| 901 | 974 | runControlPlane({ |
| @@ -922,15 +995,21 @@ export const useCreateSource = () => · 20 unmodified lines | ||
| 922 | 995 | payload, |
| 923 | 996 | }); |
| 924 | 997 | setCachedAtomValue(context.registry, listAtom, [optimisticSource, ...previousList]); |
| 925 | − return () => { | |
| 926 | − setCachedAtomValue(context.registry, listAtom, previousList); | |
| 998 | + return { | |
| 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 | 1006 | const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId)); |
| 931 | 1007 | const currentList = getCachedAtomValue(context.registry, listAtom); |
| 932 | 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 | 1015 | setCachedAtomValue( |
| @@ -1003,13 +1082,13 @@ export const useUpdateSource = () => · 66 unmodified lines | ||
| 1003 | 1082 | }, |
| 1004 | 1083 | ); |
| 1005 | 1084 | |
| 1006 | −export const useConnectMcpSource = () => | |
| 1007 | − useSourceMutation<ConnectMcpSourcePayload, ConnectMcpSourceResult>( | |
| 1085 | +export const useStartSourceOAuth = () => | |
| 1086 | + useSourceMutation<StartSourceOAuthPayload, StartSourceOAuthResult>( | |
| 1008 | 1087 | React.useCallback( |
| 1009 | 1088 | ({ workspaceId, accountId, payload }) => |
| 1010 | 1089 | runControlPlane({ |
| 1011 | 1090 | accountId, |
| 1012 | − execute: (client) => client.sources.connectMcp({ | |
| 1091 | + execute: (client) => client.oauth.startSourceAuth({ | |
| 1013 | 1092 | path: { |
| 1014 | 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 | 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 | 1190 | export type { |
| 1083 | − ConnectMcpSourcePayload, | |
| 1084 | − ConnectMcpSourceResult, | |
| 1191 | + CompleteSourceOAuthResult, | |
| 1192 | + ConnectSourcePayload, | |
| 1193 | + ConnectSourceResult, | |
| 1085 | 1194 | CreateSecretPayload, |
| 1086 | 1195 | CreateSecretResult, |
| 1087 | 1196 | CreateSourcePayload, |
| 1088 | 1197 | DeleteSecretResult, |
| 1198 | + DiscoverSourcePayload, | |
| 1089 | 1199 | InstanceConfig, |
| 1090 | 1200 | LocalInstallation, |
| 1201 | + | |
| 1091 | 1202 | SecretListItem, |
| 1092 | 1203 | Source, |
| 1204 | + SourceDiscoveryResult, | |
| 1093 | 1205 | SourceInspection, |
| 1094 | 1206 | SourceInspectionDiscoverResult, |
| 1095 | 1207 | SourceInspectionToolDetail, |
| 1208 | + StartSourceOAuthPayload, | |
| 1209 | + StartSourceOAuthResult, | |
| 1096 | 1210 | UpdateSecretPayload, |
| 1097 | 1211 | UpdateSecretResult, |
| 1098 | 1212 | UpdateSourcePayload, |