From e471d73c342154969162faf0399da5c39d3379da Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 16 Aug 2026 22:25:04 -0700 Subject: [PATCH] Stage imports before committing --- Cargo.lock | 1 + .../components/ImportDataDialog.tsx | 201 ++++- apps/yaak-client/lib/importData.tsx | 43 +- crates-tauri/yaak-app-client/src/import.rs | 48 +- crates-tauri/yaak-app-client/src/lib.rs | 19 +- crates-tauri/yaak-app-client/src/rpc_ext.rs | 15 +- .../yaak-rpc-schema/bindings/gen_rpc.ts | 10 +- .../yaak-rpc-schema/bindings/gen_util.ts | 10 + crates/common/yaak-rpc-schema/src/lib.rs | 16 +- crates/yaak-models/bindings/gen_util.ts | 10 + crates/yaak-models/src/util.rs | 80 ++ crates/yaak-plugins/bindings/gen_events.ts | 2 +- crates/yaak-plugins/src/events.rs | 2 + crates/yaak-plugins/src/manager.rs | 15 +- crates/yaak/Cargo.toml | 1 + crates/yaak/src/import.rs | 843 ++++++++++++++++-- packages/platform/src/web/commands.ts | 1 + .../src/bindings/gen_events.ts | 2 +- packages/plugin-runtime/src/PluginInstance.ts | 1 + 19 files changed, 1171 insertions(+), 149 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc028071..4cf3929f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11199,6 +11199,7 @@ dependencies = [ "base64 0.22.1", "log 0.4.29", "md5 0.8.0", + "rusqlite", "serde_json", "tempfile", "thiserror 2.0.17", diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 01e098e6..bf1831fe 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -1,17 +1,28 @@ +import type { Folder, ImportDestination, ImportPlan, Workspace } from "@yaakapp-internal/models"; +import { HStack, Icon, VStack } from "@yaakapp-internal/ui"; import { platform } from "@yaakapp-internal/platform"; -import { Icon, VStack } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useEffect, useRef, useState } from "react"; import { useLocalStorage } from "react-use"; +import { pluralizeCount } from "../lib/pluralize"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { Button } from "./core/Button"; +import { Checkbox } from "./core/Checkbox"; import { PlainInput } from "./core/PlainInput"; +import { RadioCards } from "./core/RadioCards"; interface Props { - importFile: (filePath: string) => Promise; - importUrl: (url: string) => Promise; + currentWorkspace: Workspace | null; + selectedFolder: Folder | null; + planFile: (filePath: string, destination: ImportDestination) => Promise; + planUrl: (url: string, destination: ImportDestination) => Promise; + commit: (plan: ImportPlan) => Promise; + cancel: () => void; + onError: (err: unknown) => void; } +type DestinationChoice = "new_workspace" | "current_workspace"; + /** * An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a * bare host like `example.com/openapi.json` still works (the backend defaults it to https). @@ -31,8 +42,21 @@ function fileName(path: string): string { return path.split(/[/\\]/).at(-1) || path; } -export function ImportDataDialog({ importFile, importUrl }: Props) { +export function ImportDataDialog({ + currentWorkspace, + selectedFolder, + planFile, + planUrl, + commit, + cancel, + onError, +}: Props) { const [isLoading, setIsLoading] = useState(false); + const [plan, setPlan] = useState(null); + const [destinationChoice, setDestinationChoice] = useState( + currentWorkspace == null ? "new_workspace" : "current_workspace", + ); + const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null); // A file path or a URL. Both inputs write here, so there is only ever one thing to import const [source, setSource] = useLocalStorage("importPathOrUrl", null); const [forceUpdateKey, setForceUpdateKey] = useState(0); @@ -71,19 +95,110 @@ export function ImportDataDialog({ importFile, importUrl }: Props) { selectSource(selected); }; - const handleImport = async () => { + const destination = (): ImportDestination => { + if (destinationChoice === "current_workspace" && currentWorkspace != null) { + return { + type: "current_workspace", + workspaceId: currentWorkspace.id, + folderId: targetSelectedFolder ? selectedFolder?.id : undefined, + }; + } + return { type: "new_workspace" }; + }; + + const handlePreview = async () => { setIsLoading(true); try { - if (filePath != null) { - await importFile(filePath); - } else { - await importUrl(trimmedSource); - } + const nextPlan = + filePath != null + ? await planFile(filePath, destination()) + : await planUrl(trimmedSource, destination()); + setPlan(nextPlan); + } catch (err) { + onError(err); } finally { setIsLoading(false); } }; + const handleCommit = async () => { + if (plan == null) return; + setIsLoading(true); + try { + await commit(plan); + } catch (err) { + onError(err); + } finally { + setIsLoading(false); + } + }; + + if (plan != null) { + const counts = [ + ["Workspace", plan.resources.workspaces.length], + ["Environment", plan.resources.environments.length], + ["Folder", plan.resources.folders.length], + ["HTTP Request", plan.resources.httpRequests.length], + ["gRPC Request", plan.resources.grpcRequests.length], + ["WebSocket Request", plan.resources.websocketRequests.length], + ] as const; + const destinationLabel = + plan.destination.type === "new_workspace" + ? "New workspace" + : selectedFolder != null && plan.destination.folderId === selectedFolder.id + ? `${currentWorkspace?.name ?? "Current workspace"} / ${selectedFolder.name}` + : (currentWorkspace?.name ?? "Current workspace"); + + return ( + +
+ + +
+ +
+
Resources
+
    + {counts + .filter(([, count]) => count > 0) + .map(([label, count]) => ( +
  • {pluralizeCount(label, count)}
  • + ))} +
+
+ + {plan.warnings.length > 0 && ( +
+
Import details
+
+ {plan.warnings.map((warning) => ( +
+ +
+
{warning.title}
+
{warning.detail}
+
+
+ ))} +
+
+ )} + + + + + +
+ ); + } + return ( @@ -115,25 +230,69 @@ export function ImportDataDialog({ importFile, importUrl }: Props) { + + - Import destination + + {destinationChoice === "current_workspace" && selectedFolder != null && ( + + )} + + + + - + ); } + +function PreviewRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/apps/yaak-client/lib/importData.tsx b/apps/yaak-client/lib/importData.tsx index 4f203ea9..71513206 100644 --- a/apps/yaak-client/lib/importData.tsx +++ b/apps/yaak-client/lib/importData.tsx @@ -1,10 +1,13 @@ -import type { BatchUpsertResult } from "@yaakapp-internal/models"; +import type { BatchUpsertResult, ImportDestination, ImportPlan } from "@yaakapp-internal/models"; import { FormattedError, VStack } from "@yaakapp-internal/ui"; import { Button } from "../components/core/Button"; import { ImportDataDialog } from "../components/ImportDataDialog"; +import { activeFolderAtom } from "../hooks/useActiveFolder"; +import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { createFastMutation } from "../hooks/useFastMutation"; import { showAlert } from "./alert"; import { showDialog } from "./dialog"; +import { jotaiStore } from "./jotai"; import { pluralizeCount } from "./pluralize"; import { router } from "./router"; import { rpc } from "./rpc"; @@ -21,29 +24,41 @@ export const importData = createFastMutation({ }, mutationFn: async () => { return new Promise((resolve, reject) => { + const currentWorkspace = jotaiStore.get(activeWorkspaceAtom); + const selectedFolder = jotaiStore.get(activeFolderAtom); showDialog({ id: "import", title: "Import Data", size: "sm", + onClose: resolve, render: ({ hide }) => { - const importAndHide = async (runImport: () => Promise) => { - try { - await finishImport(await runImport()); - resolve(); - } catch (err) { - reject(err); - } finally { - hide(); - } + const cancel = () => { + hide(); + resolve(); + }; + const fail = (err: unknown) => { + hide(); + reject(err); + }; + const commit = async (plan: ImportPlan) => { + const imported = await rpc("cmd_commit_import", { plan }); + hide(); + await finishImport(imported); + resolve(); }; return ( - importAndHide(() => rpc("cmd_import_data", { filePath })) + currentWorkspace={currentWorkspace} + selectedFolder={selectedFolder} + planFile={(filePath: string, destination: ImportDestination) => + rpc("cmd_import_data", { filePath, destination }) } - importUrl={(url) => - importAndHide(() => rpc("cmd_import_url", { url })) + planUrl={(url: string, destination: ImportDestination) => + rpc("cmd_import_url", { url, destination }) } + commit={commit} + cancel={cancel} + onError={fail} /> ); }, diff --git a/crates-tauri/yaak-app-client/src/import.rs b/crates-tauri/yaak-app-client/src/import.rs index 3ed04903..1da6d0f5 100644 --- a/crates-tauri/yaak-app-client/src/import.rs +++ b/crates-tauri/yaak-app-client/src/import.rs @@ -4,53 +4,63 @@ use crate::models_ext::QueryManagerExt; use std::fs::read_to_string; use std::io::ErrorKind; use tauri::{Manager, Runtime, WebviewWindow}; -use yaak::import::{self, ImportDataParams}; +use yaak::import::{self, PlanImportDataParams}; use yaak_api::{ApiClientKind, yaak_api_client}; -use yaak_core::WorkspaceContext; -use yaak_models::util::BatchUpsertResult; +use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan}; use yaak_plugins::manager::PluginManager; -use yaak_tauri_utils::window::WorkspaceWindowTrait; pub(crate) async fn import_data( window: &WebviewWindow, file_path: &str, ) -> Result { - let contents = read_import_file(file_path)?; - import_contents(window, &contents).await + let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?; + commit_import(window, plan) } -pub(crate) async fn import_url( +pub(crate) async fn plan_import_data( + window: &WebviewWindow, + file_path: &str, + destination: ImportDestination, +) -> Result { + let contents = read_import_file(file_path)?; + plan_import_contents(window, &contents, destination).await +} + +pub(crate) async fn plan_import_url( window: &WebviewWindow, url: &str, -) -> Result { + destination: ImportDestination, +) -> Result { let contents = fetch_import_url(window, url).await?; - import_contents(window, &contents).await + plan_import_contents(window, &contents, destination).await } -async fn import_contents( +async fn plan_import_contents( window: &WebviewWindow, contents: &str, -) -> Result { + destination: ImportDestination, +) -> Result { let plugin_manager = window.state::(); let query_manager = window.db_manager(); let plugin_context = window.plugin_context(); - let workspace_context = WorkspaceContext { - workspace_id: window.workspace_id(), - environment_id: window.environment_id(), - cookie_jar_id: window.cookie_jar_id(), - request_id: None, - }; - Ok(import::import_data(ImportDataParams { + Ok(import::plan_import_data(PlanImportDataParams { query_manager: &query_manager, plugin_manager: &plugin_manager, plugin_context: &plugin_context, - workspace_context, + destination, contents, }) .await?) } +pub(crate) fn commit_import( + window: &WebviewWindow, + plan: ImportPlan, +) -> Result { + Ok(import::commit_import_plan(&window.db_manager(), plan)?) +} + /// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same /// pipeline as a file on disk. /// diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 76c75fbc..7fecf463 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -4,7 +4,7 @@ use crate::error::Error::GenericError; use crate::error::Result; use crate::grpc::{build_metadata, metadata_to_map}; use crate::http_request::send_http_request; -use crate::import::{import_data, import_url}; +use crate::import::{commit_import, plan_import_data, plan_import_url}; use crate::models_ext::{BlobManagerExt, QueryManagerExt}; use crate::notifications::YaakNotifier; use crate::render::{render_grpc_request, render_template}; @@ -40,7 +40,7 @@ use yaak_models::models::{ CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent, GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace, }; -use yaak_models::util::{BatchUpsertResult, UpdateSource}; +use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource}; use yaak_plugins::events::{ Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext, RenderPurpose, ShowToastRequest, @@ -1013,15 +1013,24 @@ async fn cmd_get_sse_events( async fn cmd_import_data( window: WebviewWindow, file_path: &str, -) -> YaakResult { - import_data(&window, file_path).await + destination: ImportDestination, +) -> YaakResult { + plan_import_data(&window, file_path, destination).await } async fn cmd_import_url( window: WebviewWindow, url: &str, + destination: ImportDestination, +) -> YaakResult { + plan_import_url(&window, url, destination).await +} + +async fn cmd_commit_import( + window: WebviewWindow, + plan: ImportPlan, ) -> YaakResult { - import_url(&window, url).await + commit_import(&window, plan) } diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index 07313b49..ee9a7434 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -40,7 +40,7 @@ use yaak_models::models::{ HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, }; use yaak_models::query_manager::QueryManager; -use yaak_models::util::BatchUpsertResult; +use yaak_models::util::{BatchUpsertResult, ImportPlan}; use yaak_plugins::events::{ CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse, @@ -441,12 +441,16 @@ async fn cmd_get_http_response_events(ctx: ClientCtx, req: CmdGet Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?) } -async fn cmd_import_data(ctx: ClientCtx, req: CmdImportDataReq) -> Result { - Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?) +async fn cmd_import_data(ctx: ClientCtx, req: CmdImportDataReq) -> Result { + Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?) } -async fn cmd_import_url(ctx: ClientCtx, req: CmdImportUrlReq) -> Result { - Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?) +async fn cmd_import_url(ctx: ClientCtx, req: CmdImportUrlReq) -> Result { + Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?) +} + +async fn cmd_commit_import(ctx: ClientCtx, req: CmdCommitImportReq) -> Result { + Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?) } async fn cmd_http_request_actions(ctx: ClientCtx, req: CmdHttpRequestActionsReq) -> Result> { @@ -843,4 +847,3 @@ async fn cmd_plugins_updates(ctx: ClientCtx, _req: CmdPluginsUpda async fn cmd_plugins_update_all(ctx: ClientCtx, _req: CmdPluginsUpdateAllReq) -> Result> { Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?) } - diff --git a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts index f94107fe..1be78d1e 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts @@ -6,7 +6,7 @@ import type { ServiceDefinition } from "./gen_grpc"; import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models"; import type { PluginMetadata } from "./gen_search"; import type { SyncOp } from "./gen_sync"; -import type { BatchUpsertResult } from "./gen_util"; +import type { BatchUpsertResult, ImportDestination, ImportPlan } from "./gen_util"; import type { Tokens } from "./parser"; import type { ServerSentEvent } from "./sse"; @@ -29,6 +29,8 @@ export type CmdCallWorkspaceActionReq = { req: CallWorkspaceActionRequest, }; export type CmdCheckForUpdatesReq = Record; +export type CmdCommitImportReq = { plan: ImportPlan, }; + export type CmdCurlToRequestReq = { command: string, workspaceId: string, }; export type CmdDecryptTemplateReq = { template: string, }; @@ -141,9 +143,9 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, }; export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, }; -export type CmdImportDataReq = { filePath: string, }; +export type CmdImportDataReq = { filePath: string, destination: ImportDestination, }; -export type CmdImportUrlReq = { url: string, }; +export type CmdImportUrlReq = { url: string, destination: ImportDestination, }; export type CmdMetadataReq = Record; @@ -248,6 +250,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, }; export type ModelsWorkspaceModelsReq = { workspaceId: string | null, }; -export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, BatchUpsertResult], cmd_import_url: [CmdImportUrlReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; +export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array], models_grpc_events: [ModelsGrpcEventsReq, Array], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogReq, Array], cmd_git_log_for_file: [CmdGitLogForFileReq, Array], cmd_git_file_diff_for_commit: [CmdGitFileDiffForCommitReq, GitFileDiff], cmd_git_initialize: [CmdGitInitializeReq, null], cmd_git_clone: [CmdGitCloneReq, CloneResult], cmd_git_commit: [CmdGitCommitReq, null], cmd_git_fetch_all: [CmdGitFetchAllReq, null], cmd_git_push: [CmdGitPushReq, PushResult], cmd_git_pull: [CmdGitPullReq, PullResult], cmd_git_pull_force_reset: [CmdGitPullForceResetReq, PullResult], cmd_git_pull_merge: [CmdGitPullMergeReq, PullResult], cmd_git_add: [CmdGitAddReq, null], cmd_git_unstage: [CmdGitUnstageReq, null], cmd_git_reset_changes: [CmdGitResetChangesReq, null], cmd_git_restore_files: [CmdGitRestoreFilesReq, null], cmd_git_restore_file_from_commit: [CmdGitRestoreFileFromCommitReq, null], cmd_git_add_credential: [CmdGitAddCredentialReq, null], cmd_git_remotes: [CmdGitRemotesReq, Array], cmd_git_add_remote: [CmdGitAddRemoteReq, GitRemote], cmd_git_rm_remote: [CmdGitRmRemoteReq, null], cmd_sync_calculate: [CmdSyncCalculateReq, Array], cmd_sync_calculate_fs: [CmdSyncCalculateFsReq, Array], cmd_sync_apply: [CmdSyncApplyReq, null], cmd_ws_delete_connections: [CmdWsDeleteConnectionsReq, null], cmd_ws_send: [CmdWsSendReq, WebsocketConnection], cmd_ws_close: [CmdWsCloseReq, WebsocketConnection], cmd_ws_connect: [CmdWsConnectReq, WebsocketConnection], cmd_plugins_search: [CmdPluginsSearchReq, PluginSearchResponse], cmd_plugins_install: [CmdPluginsInstallReq, null], cmd_plugins_install_from_directory: [CmdPluginsInstallFromDirectoryReq, Plugin], cmd_plugins_uninstall: [CmdPluginsUninstallReq, Plugin], cmd_plugin_init_errors: [CmdPluginInitErrorsReq, Array<[string, string]>], cmd_plugins_updates: [CmdPluginsUpdatesReq, PluginUpdatesResponse], cmd_plugins_update_all: [CmdPluginsUpdateAllReq, Array], cmd_git_watch_worktree_status: [CmdGitWatchWorktreeStatusReq, GitWatchResult], cmd_sync_watch: [CmdSyncWatchReq, WatchResult], }; export type WatchResult = { unlistenEvent: string, }; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_util.ts b/crates/common/yaak-rpc-schema/bindings/gen_util.ts index f1bf4fa2..52c28465 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_util.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_util.ts @@ -2,3 +2,13 @@ import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; export type BatchUpsertResult = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; + +export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, }; + +export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array, }; + +export type ImportPlanWarning = { title: string, detail: string, }; + +export type ImportPlanResources = { workspaces: Array>, environments: Array>, folders: Array>, httpRequests: Array>, grpcRequests: Array>, websocketRequests: Array>, }; + +export type PlannedImportResource = { sourceKey?: string, resource: T, }; diff --git a/crates/common/yaak-rpc-schema/src/lib.rs b/crates/common/yaak-rpc-schema/src/lib.rs index 79416228..4425dc1f 100644 --- a/crates/common/yaak-rpc-schema/src/lib.rs +++ b/crates/common/yaak-rpc-schema/src/lib.rs @@ -23,7 +23,7 @@ use yaak_models::models::{ AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, }; -use yaak_models::util::BatchUpsertResult; +use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan}; use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse}; use yaak_plugins::events::{ CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, @@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq { #[ts(export, export_to = "gen_rpc.ts")] pub struct CmdImportDataReq { pub file_path: String, + pub destination: ImportDestination, } #[derive(Debug, Deserialize, TS)] @@ -236,6 +237,14 @@ pub struct CmdImportDataReq { #[ts(export, export_to = "gen_rpc.ts")] pub struct CmdImportUrlReq { pub url: String, + pub destination: ImportDestination, +} + +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct CmdCommitImportReq { + pub plan: ImportPlan, } #[derive(Debug, Deserialize, TS)] @@ -909,8 +918,9 @@ macro_rules! with_commands { cmd_http_request_body(CmdHttpRequestBodyReq) -> Option>, cmd_get_sse_events(CmdGetSseEventsReq) -> Vec, cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec, - cmd_import_data(CmdImportDataReq) -> BatchUpsertResult, - cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult, + cmd_import_data(CmdImportDataReq) -> ImportPlan, + cmd_import_url(CmdImportUrlReq) -> ImportPlan, + cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult, cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec, cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec, cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (), diff --git a/crates/yaak-models/bindings/gen_util.ts b/crates/yaak-models/bindings/gen_util.ts index f1bf4fa2..52c28465 100644 --- a/crates/yaak-models/bindings/gen_util.ts +++ b/crates/yaak-models/bindings/gen_util.ts @@ -2,3 +2,13 @@ import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; export type BatchUpsertResult = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; + +export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, }; + +export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array, }; + +export type ImportPlanWarning = { title: string, detail: string, }; + +export type ImportPlanResources = { workspaces: Array>, environments: Array>, folders: Array>, httpRequests: Array>, grpcRequests: Array>, websocketRequests: Array>, }; + +export type PlannedImportResource = { sourceKey?: string, resource: T, }; diff --git a/crates/yaak-models/src/util.rs b/crates/yaak-models/src/util.rs index 62cb7bd7..2f13c457 100644 --- a/crates/yaak-models/src/util.rs +++ b/crates/yaak-models/src/util.rs @@ -85,6 +85,86 @@ pub struct BatchUpsertResult { pub websocket_requests: Vec, } +/// Where a staged import will be committed. +/// +/// The current workspace and optional folder IDs are captured in the plan so the preview describes +/// the exact destination that confirmation will use. +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "snake_case", tag = "type")] +#[ts(export, export_to = "gen_util.ts")] +pub enum ImportDestination { + NewWorkspace, + CurrentWorkspace { + #[serde(rename = "workspaceId")] + workspace_id: String, + #[serde(rename = "folderId")] + #[ts(optional)] + folder_id: Option, + }, +} + +/// A model staged for import. +/// +/// `source_key` is intentionally part of the plan boundary even though the first import slice does +/// not persist it. Future linked imports can populate it without changing how plans contain models. +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_util.ts")] +pub struct PlannedImportResource { + #[ts(optional)] + pub source_key: Option, + pub resource: T, +} + +impl PlannedImportResource { + pub fn new(resource: T) -> Self { + Self { source_key: None, resource } + } +} + +#[derive(Default, Debug, Clone, Deserialize, Serialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_util.ts")] +pub struct ImportPlanResources { + pub workspaces: Vec>, + pub environments: Vec>, + pub folders: Vec>, + pub http_requests: Vec>, + pub grpc_requests: Vec>, + pub websocket_requests: Vec>, +} + +impl ImportPlanResources { + pub fn into_batch(self) -> BatchUpsertResult { + BatchUpsertResult { + workspaces: self.workspaces.into_iter().map(|v| v.resource).collect(), + environments: self.environments.into_iter().map(|v| v.resource).collect(), + folders: self.folders.into_iter().map(|v| v.resource).collect(), + http_requests: self.http_requests.into_iter().map(|v| v.resource).collect(), + grpc_requests: self.grpc_requests.into_iter().map(|v| v.resource).collect(), + websocket_requests: self.websocket_requests.into_iter().map(|v| v.resource).collect(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_util.ts")] +pub struct ImportPlanWarning { + pub title: String, + pub detail: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_util.ts")] +pub struct ImportPlan { + pub importer: String, + pub destination: ImportDestination, + pub resources: ImportPlanResources, + pub warnings: Vec, +} + pub fn get_workspace_export_resources( db: &ClientDb, yaak_version: &str, diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index 2daff845..d601beb6 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -474,7 +474,7 @@ export type ImportRequest = { content: string, }; export type ImportResources = { workspaces: Array, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; -export type ImportResponse = { resources: ImportResources, }; +export type ImportResponse = { importer: string, resources: ImportResources, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; diff --git a/crates/yaak-plugins/src/events.rs b/crates/yaak-plugins/src/events.rs index 5e97e8e1..05d3b331 100644 --- a/crates/yaak-plugins/src/events.rs +++ b/crates/yaak-plugins/src/events.rs @@ -247,6 +247,8 @@ pub struct ImportRequest { #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_events.ts")] pub struct ImportResponse { + /// Display name of the importer that recognized the input. + pub importer: String, pub resources: ImportResources, } diff --git a/crates/yaak-plugins/src/manager.rs b/crates/yaak-plugins/src/manager.rs index ed3666cc..cf7ad6c2 100644 --- a/crates/yaak-plugins/src/manager.rs +++ b/crates/yaak-plugins/src/manager.rs @@ -1104,8 +1104,19 @@ impl PluginManager { .await?; // TODO: Don't just return the first valid response - let result = reply_events.into_iter().find_map(|e| match e.payload { - InternalEventPayload::ImportResponse(resp) => Some(resp), + let result = reply_events.into_iter().find_map(|e| match e { + InternalEvent { + plugin_name, + payload: InternalEventPayload::ImportResponse(mut resp), + .. + } => { + // Older plugin runtimes do not include the importer's display name. The plugin + // package name is still enough to identify the detected format in that case. + if resp.importer.is_empty() { + resp.importer = plugin_name; + } + Some(resp) + } _ => None, }); diff --git a/crates/yaak/Cargo.toml b/crates/yaak/Cargo.toml index 03b8e161..f81740ba 100644 --- a/crates/yaak/Cargo.toml +++ b/crates/yaak/Cargo.toml @@ -21,5 +21,6 @@ yaak-templates = { workspace = true } yaak-tls = { workspace = true } [dev-dependencies] +rusqlite = { version = "0.38", features = ["bundled"] } tempfile = "3" tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/yaak/src/import.rs b/crates/yaak/src/import.rs index 5ba7aa3e..c8b86a5d 100644 --- a/crates/yaak/src/import.rs +++ b/crates/yaak/src/import.rs @@ -1,129 +1,826 @@ use crate::Result; use log::info; -use std::collections::BTreeMap; -use yaak_core::WorkspaceContext; +use std::collections::{BTreeMap, BTreeSet}; +use yaak_models::client_db::ClientDb; use yaak_models::models::{ - Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace, + DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo, + WebsocketRequest, Workspace, }; use yaak_models::query_manager::QueryManager; -use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt}; +use yaak_models::util::{ + BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanResources, ImportPlanWarning, + PlannedImportResource, UpdateSource, +}; use yaak_plugins::events::{ImportResources, PluginContext}; use yaak_plugins::manager::PluginManager; -pub struct ImportDataParams<'a> { +pub struct PlanImportDataParams<'a> { pub query_manager: &'a QueryManager, pub plugin_manager: &'a PluginManager, pub plugin_context: &'a PluginContext, - pub workspace_context: WorkspaceContext, + pub destination: ImportDestination, pub contents: &'a str, } -pub async fn import_data(params: ImportDataParams<'_>) -> Result { +/// Parse importer output and turn it into a commit-ready plan without mutating the database. +pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result { let import_result = params.plugin_manager.import_data(params.plugin_context, params.contents).await?; - import_resources(params.query_manager, params.workspace_context, import_result.resources) + plan_import_resources( + params.query_manager, + import_result.importer, + params.destination, + import_result.resources, + ) } -pub fn import_resources( +/// Remap parsed importer resources into their selected destination. +/// +/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an +/// existing model and also makes the plan safe to inspect before it is committed. +pub fn plan_import_resources( query_manager: &QueryManager, - workspace_context: WorkspaceContext, + importer: String, + destination: ImportDestination, resources: ImportResources, -) -> Result { - let mut id_map: BTreeMap = BTreeMap::new(); +) -> Result { + let mut warnings = Vec::new(); + validate_destination(query_manager, &destination)?; - let workspaces: Vec = resources - .workspaces - .into_iter() - .map(|mut v| { - v.id = maybe_gen_id::(&workspace_context, v.id.as_str(), &mut id_map); - v - }) - .collect(); + let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::>(); + let mut folder_ids = BTreeMap::new(); + for folder in &resources.folders { + folder_ids.insert(folder.id.clone(), Folder::generate_id()); + } - let environments: Vec = resources - .environments - .into_iter() - .map(|mut v| { - v.id = maybe_gen_id::(&workspace_context, v.id.as_str(), &mut id_map); - v.workspace_id = - maybe_gen_id::(&workspace_context, v.workspace_id.as_str(), &mut id_map); - match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) { - ("folder", Some(parent_id)) => { - v.parent_id = - Some(maybe_gen_id::(&workspace_context, parent_id, &mut id_map)); - } - ("", _) => { - v.parent_model = "workspace".to_string(); - } - _ => { - v.parent_id = None; - } - }; - v - }) - .collect(); + let mut workspace_ids = BTreeMap::new(); + let mut workspaces = Vec::new(); + let (default_workspace_id, target_folder_id) = match &destination { + ImportDestination::NewWorkspace => { + for source in &resources.workspaces { + let mut workspace = source.clone(); + workspace.id = Workspace::generate_id(); + workspace_ids.insert(source.id.clone(), workspace.id.clone()); + workspaces.push(PlannedImportResource::new(workspace)); + } - let folders: Vec = resources + if workspaces.is_empty() { + let workspace = Workspace { + id: Workspace::generate_id(), + model: "workspace".to_string(), + name: format!("{} Import", display_importer_name(&importer)), + setting_follow_redirects: true, + setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE, + setting_validate_certificates: true, + setting_send_cookies: true, + setting_store_cookies: true, + ..Default::default() + }; + workspaces.push(PlannedImportResource::new(workspace)); + } + + (workspaces[0].resource.id.clone(), None) + } + ImportDestination::CurrentWorkspace { workspace_id, folder_id } => { + for source in &resources.workspaces { + workspace_ids.insert(source.id.clone(), workspace_id.clone()); + } + if !resources.workspaces.is_empty() { + let destination_workspace = query_manager.connect().get_workspace(workspace_id)?; + let skipped_fields = resources + .workspaces + .iter() + .flat_map(|source| { + workspace_fields_not_imported(source, &destination_workspace) + }) + .collect::>(); + if !skipped_fields.is_empty() { + let source = if resources.workspaces.len() == 1 { + resources.workspaces[0].name.clone() + } else { + format!("{} imported workspaces", resources.workspaces.len()) + }; + warnings.push(ImportPlanWarning { + title: "Workspace settings skipped".to_string(), + detail: format!("{source} · {}", display_list(&skipped_fields)), + }); + } + } + (workspace_id.clone(), folder_id.clone()) + } + }; + + let resolve_workspace_id = |source_id: &str| { + workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone()) + }; + + let resolve_folder_id = |source_id: Option| match source_id { + Some(source_id) if source_folder_ids.contains(&source_id) => { + folder_ids.get(&source_id).cloned() + } + _ => target_folder_id.clone(), + }; + + let folders = resources .folders .into_iter() - .map(|mut v| { - v.id = maybe_gen_id::(&workspace_context, v.id.as_str(), &mut id_map); - v.workspace_id = - maybe_gen_id::(&workspace_context, v.workspace_id.as_str(), &mut id_map); - v.folder_id = maybe_gen_id_opt::(&workspace_context, v.folder_id, &mut id_map); - v + .map(|mut folder| { + folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id); + folder.workspace_id = resolve_workspace_id(&folder.workspace_id); + folder.folder_id = resolve_folder_id(folder.folder_id); + PlannedImportResource::new(folder) }) .collect(); - let http_requests: Vec = resources + let http_requests = resources .http_requests .into_iter() - .map(|mut v| { - v.id = maybe_gen_id::(&workspace_context, v.id.as_str(), &mut id_map); - v.workspace_id = - maybe_gen_id::(&workspace_context, v.workspace_id.as_str(), &mut id_map); - v.folder_id = maybe_gen_id_opt::(&workspace_context, v.folder_id, &mut id_map); - v + .map(|mut request| { + request.id = HttpRequest::generate_id(); + request.workspace_id = resolve_workspace_id(&request.workspace_id); + request.folder_id = resolve_folder_id(request.folder_id); + PlannedImportResource::new(request) }) .collect(); - let grpc_requests: Vec = resources + let grpc_requests = resources .grpc_requests .into_iter() - .map(|mut v| { - v.id = maybe_gen_id::(&workspace_context, v.id.as_str(), &mut id_map); - v.workspace_id = - maybe_gen_id::(&workspace_context, v.workspace_id.as_str(), &mut id_map); - v.folder_id = maybe_gen_id_opt::(&workspace_context, v.folder_id, &mut id_map); - v + .map(|mut request| { + request.id = GrpcRequest::generate_id(); + request.workspace_id = resolve_workspace_id(&request.workspace_id); + request.folder_id = resolve_folder_id(request.folder_id); + PlannedImportResource::new(request) }) .collect(); - let websocket_requests: Vec = resources + let websocket_requests = resources .websocket_requests .into_iter() - .map(|mut v| { - v.id = maybe_gen_id::(&workspace_context, v.id.as_str(), &mut id_map); - v.workspace_id = - maybe_gen_id::(&workspace_context, v.workspace_id.as_str(), &mut id_map); - v.folder_id = maybe_gen_id_opt::(&workspace_context, v.folder_id, &mut id_map); - v + .map(|mut request| { + request.id = WebsocketRequest::generate_id(); + request.workspace_id = resolve_workspace_id(&request.workspace_id); + request.folder_id = resolve_folder_id(request.folder_id); + PlannedImportResource::new(request) }) .collect(); - info!("Importing data"); + let importing_into_current = matches!(destination, ImportDestination::CurrentWorkspace { .. }); + let mut separated_base_environments = Vec::new(); + let mut converted_duplicate_base_environment = false; + let mut converted_duplicate_folder_environment = false; + let mut base_environment_workspaces = BTreeSet::new(); + let mut folder_environment_ids = BTreeSet::new(); + let environments = resources + .environments + .into_iter() + .map(|mut environment| { + environment.id = Environment::generate_id(); + environment.workspace_id = resolve_workspace_id(&environment.workspace_id); - query_manager.with_tx(|tx| { - tx.batch_upsert( + match (environment.parent_model.as_str(), environment.parent_id.clone()) { + ("workspace", _) if importing_into_current => { + environment.parent_model = "environment".to_string(); + environment.parent_id = None; + let source_name = environment.name.clone(); + environment.name = format!("{} (Imported)", environment.name); + separated_base_environments.push(( + source_name, + environment.name.clone(), + environment.variables.len(), + )); + } + ("workspace", _) => { + environment.parent_id = None; + if !base_environment_workspaces.insert(environment.workspace_id.clone()) { + environment.parent_model = "environment".to_string(); + environment.name = format!("{} (Imported)", environment.name); + converted_duplicate_base_environment = true; + } + } + ("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => { + environment.parent_id = folder_ids.get(&parent_id).cloned(); + if let Some(parent_id) = &environment.parent_id + && !folder_environment_ids.insert(parent_id.clone()) + { + environment.parent_model = "environment".to_string(); + environment.parent_id = None; + converted_duplicate_folder_environment = true; + } + } + ("folder", _) => { + // Never attach an imported folder environment to an existing folder: the model + // layer permits only one and would otherwise delete the destination's value. + environment.parent_model = "environment".to_string(); + environment.parent_id = None; + } + ("environment", _) => { + environment.parent_id = None; + } + _ => { + environment.parent_model = "environment".to_string(); + environment.parent_id = None; + } + } + + PlannedImportResource::new(environment) + }) + .collect(); + + for (source_name, imported_name, variable_count) in separated_base_environments { + let variables = if variable_count == 1 { "variable" } else { "variables" }; + warnings.push(ImportPlanWarning { + title: "Base environment kept separate".to_string(), + detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"), + }); + } + if converted_duplicate_base_environment { + warnings.push(ImportPlanWarning { + title: "Base environments separated".to_string(), + detail: "Only the first remains the base environment".to_string(), + }); + } + if converted_duplicate_folder_environment { + warnings.push(ImportPlanWarning { + title: "Folder environments separated".to_string(), + detail: "Only the first remains attached to each folder".to_string(), + }); + } + + Ok(ImportPlan { + importer, + destination, + resources: ImportPlanResources { workspaces, environments, folders, http_requests, grpc_requests, websocket_requests, + }, + warnings, + }) +} + +/// Commit a previously prepared plan in one transaction. +pub fn commit_import_plan( + query_manager: &QueryManager, + plan: ImportPlan, +) -> Result { + validate_plan(&plan)?; + let resources = plan.resources.into_batch(); + + info!("Committing staged import from {}", plan.importer); + query_manager.with_tx(|tx| { + validate_destination_db(tx, &plan.destination)?; + tx.batch_upsert( + resources.workspaces, + resources.environments, + resources.folders, + resources.http_requests, + resources.grpc_requests, + resources.websocket_requests, &UpdateSource::Import, ) .map_err(crate::Error::from) }) } + +fn validate_destination( + query_manager: &QueryManager, + destination: &ImportDestination, +) -> Result<()> { + let db = query_manager.connect(); + validate_destination_db(&db, destination) +} + +fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> { + let ImportDestination::CurrentWorkspace { workspace_id, folder_id } = destination else { + return Ok(()); + }; + + db.get_workspace(workspace_id)?; + if let Some(folder_id) = folder_id { + let folder = db.get_folder(folder_id)?; + if folder.workspace_id != *workspace_id { + return Err(yaak_models::error::Error::GenericError(format!( + "Folder {folder_id} does not belong to workspace {workspace_id}" + )) + .into()); + } + } + Ok(()) +} + +fn validate_plan(plan: &ImportPlan) -> Result<()> { + let invalid = |message: String| -> Result<()> { + Err(yaak_models::error::Error::GenericError(message).into()) + }; + + match &plan.destination { + ImportDestination::CurrentWorkspace { workspace_id, .. } => { + if !plan.resources.workspaces.is_empty() { + return invalid( + "A current-workspace import plan must not contain workspace updates" + .to_string(), + ); + } + + let all_workspace_ids = plan + .resources + .environments + .iter() + .map(|v| &v.resource.workspace_id) + .chain(plan.resources.folders.iter().map(|v| &v.resource.workspace_id)) + .chain(plan.resources.http_requests.iter().map(|v| &v.resource.workspace_id)) + .chain(plan.resources.grpc_requests.iter().map(|v| &v.resource.workspace_id)) + .chain(plan.resources.websocket_requests.iter().map(|v| &v.resource.workspace_id)); + if all_workspace_ids.into_iter().any(|id| id != workspace_id) { + return invalid( + "A current-workspace import plan contains resources for another workspace" + .to_string(), + ); + } + + if plan.resources.environments.iter().any(|v| v.resource.parent_model == "workspace") { + return invalid( + "A current-workspace import plan must not replace the base environment" + .to_string(), + ); + } + } + ImportDestination::NewWorkspace => { + let workspace_ids = plan + .resources + .workspaces + .iter() + .map(|v| v.resource.id.as_str()) + .collect::>(); + if workspace_ids.is_empty() { + return invalid("A new-workspace import plan has no workspace".to_string()); + } + let all_workspace_ids = plan + .resources + .environments + .iter() + .map(|v| v.resource.workspace_id.as_str()) + .chain(plan.resources.folders.iter().map(|v| v.resource.workspace_id.as_str())) + .chain( + plan.resources.http_requests.iter().map(|v| v.resource.workspace_id.as_str()), + ) + .chain( + plan.resources.grpc_requests.iter().map(|v| v.resource.workspace_id.as_str()), + ) + .chain( + plan.resources + .websocket_requests + .iter() + .map(|v| v.resource.workspace_id.as_str()), + ); + if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) { + return invalid( + "A new-workspace import plan contains resources outside its workspaces" + .to_string(), + ); + } + + let mut base_environment_workspaces = BTreeSet::new(); + if plan.resources.environments.iter().any(|v| { + v.resource.parent_model == "workspace" + && !base_environment_workspaces.insert(v.resource.workspace_id.as_str()) + }) { + return invalid( + "A new-workspace import plan contains multiple base environments for one workspace" + .to_string(), + ); + } + } + } + + let planned_folder_ids = + plan.resources.folders.iter().map(|v| v.resource.id.as_str()).collect::>(); + if plan.resources.environments.iter().any(|v| { + v.resource.parent_model == "folder" + && v.resource.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id)) + }) { + return invalid( + "An import plan must not replace an existing folder environment".to_string(), + ); + } + + Ok(()) +} + +fn display_importer_name(importer: &str) -> &str { + importer.strip_prefix("@yaak/importer-").unwrap_or(importer) +} + +fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> { + let mut fields = Vec::new(); + if source.name != destination.name { + fields.push("workspace name"); + } + if source.description != destination.description { + fields.push("description"); + } + if source.authentication != destination.authentication + || source.authentication_type != destination.authentication_type + { + fields.push("authentication"); + } + if source.headers != destination.headers { + fields.push("default headers"); + } + if source.encryption_key_challenge != destination.encryption_key_challenge { + fields.push("encryption configuration"); + } + if source.setting_validate_certificates != destination.setting_validate_certificates { + fields.push("certificate validation"); + } + if source.setting_follow_redirects != destination.setting_follow_redirects { + fields.push("redirect behavior"); + } + if source.setting_request_timeout != destination.setting_request_timeout { + fields.push("request timeout"); + } + if source.setting_request_message_size != destination.setting_request_message_size { + fields.push("request message size"); + } + if source.setting_dns_overrides != destination.setting_dns_overrides { + fields.push("DNS overrides"); + } + if source.setting_send_cookies != destination.setting_send_cookies + || source.setting_store_cookies != destination.setting_store_cookies + { + fields.push("cookie behavior"); + } + fields +} + +fn display_list(items: &BTreeSet<&str>) -> String { + let items = items.iter().copied().collect::>(); + match items.as_slice() { + [] => String::new(), + [item] => (*item).to_string(), + [first, second] => format!("{first} and {second}"), + _ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use yaak_models::models::{EnvironmentVariable, HttpRequestHeader}; + + fn destination_workspace() -> Workspace { + Workspace { + id: "wk_destination".to_string(), + model: "workspace".to_string(), + name: "Destination".to_string(), + authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]), + authentication_type: Some("bearer".to_string()), + headers: vec![HttpRequestHeader { + enabled: true, + name: "X-Destination".to_string(), + value: "preserved".to_string(), + id: None, + }], + setting_validate_certificates: false, + setting_follow_redirects: false, + setting_request_timeout: 1234, + ..Default::default() + } + } + + fn imported_resources() -> ImportResources { + ImportResources { + workspaces: vec![Workspace { + id: "wk_source".to_string(), + model: "workspace".to_string(), + name: "Imported".to_string(), + authentication_type: Some("basic".to_string()), + setting_validate_certificates: true, + ..Default::default() + }], + environments: vec![Environment { + id: "ev_source_base".to_string(), + model: "environment".to_string(), + workspace_id: "wk_source".to_string(), + name: "Global Variables".to_string(), + parent_model: "workspace".to_string(), + variables: vec![EnvironmentVariable { + enabled: true, + name: "imported".to_string(), + value: "yes".to_string(), + id: None, + }], + ..Default::default() + }], + folders: vec![Folder { + id: "fl_source".to_string(), + model: "folder".to_string(), + workspace_id: "wk_source".to_string(), + name: "Imported Folder".to_string(), + ..Default::default() + }], + http_requests: vec![ + HttpRequest { + id: "rq_root".to_string(), + model: "http_request".to_string(), + workspace_id: "wk_source".to_string(), + name: "Root Request".to_string(), + method: "GET".to_string(), + url: "https://example.com/root".to_string(), + ..Default::default() + }, + HttpRequest { + id: "rq_nested".to_string(), + model: "http_request".to_string(), + workspace_id: "wk_source".to_string(), + folder_id: Some("fl_source".to_string()), + name: "Nested Request".to_string(), + method: "GET".to_string(), + url: "https://example.com/nested".to_string(), + ..Default::default() + }, + ], + ..Default::default() + } + } + + #[test] + fn current_workspace_plan_does_not_mutate_and_preserves_workspace_settings() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let mut destination = destination_workspace(); + let selected_folder = Folder { + id: "fl_selected".to_string(), + model: "folder".to_string(), + workspace_id: destination.id.clone(), + name: "Selected Folder".to_string(), + ..Default::default() + }; + { + let db = query_manager.connect(); + destination = db + .upsert_workspace(&destination, &UpdateSource::Import) + .expect("create destination"); + db.upsert_folder(&selected_folder, &UpdateSource::Import) + .expect("create selected folder"); + db.upsert_environment( + &Environment { + id: "ev_destination_base".to_string(), + model: "environment".to_string(), + workspace_id: destination.id.clone(), + name: "Destination Variables".to_string(), + parent_model: "workspace".to_string(), + variables: vec![EnvironmentVariable { + enabled: true, + name: "destination".to_string(), + value: "keep".to_string(), + id: None, + }], + ..Default::default() + }, + &UpdateSource::Import, + ) + .expect("create base environment"); + } + + let plan = plan_import_resources( + &query_manager, + "OpenAPI".to_string(), + ImportDestination::CurrentWorkspace { + workspace_id: destination.id.clone(), + folder_id: Some(selected_folder.id.clone()), + }, + imported_resources(), + ) + .expect("plan import"); + + // Planning performed only reads. + { + let db = query_manager.connect(); + assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1); + assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1); + assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty()); + assert_eq!( + db.list_environments_ensure_base(&destination.id).expect("list environments").len(), + 1 + ); + assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination); + } + + assert!(plan.resources.workspaces.is_empty()); + assert_eq!(plan.resources.folders[0].resource.workspace_id, destination.id); + assert_eq!( + plan.resources.folders[0].resource.folder_id.as_deref(), + Some(selected_folder.id.as_str()) + ); + let root_request = plan + .resources + .http_requests + .iter() + .find(|v| v.resource.name == "Root Request") + .expect("root request"); + assert_eq!(root_request.resource.folder_id.as_deref(), Some(selected_folder.id.as_str())); + let nested_request = plan + .resources + .http_requests + .iter() + .find(|v| v.resource.name == "Nested Request") + .expect("nested request"); + assert_eq!( + nested_request.resource.folder_id, + Some(plan.resources.folders[0].resource.id.clone()) + ); + assert_eq!(plan.resources.environments[0].resource.parent_model, "environment"); + assert!(plan.resources.environments[0].resource.name.ends_with("(Imported)")); + assert_eq!(plan.warnings.len(), 2); + assert!(plan.warnings.iter().any(|warning| { + warning.title == "Workspace settings skipped" + && warning.detail.starts_with("Imported ·") + && warning.detail.contains("authentication") + && warning.detail.contains("default headers") + })); + assert!(plan.warnings.iter().any(|warning| { + warning.title == "Base environment kept separate" + && warning.detail == "Global Variables → Global Variables (Imported) · 1 variable" + })); + + let committed = commit_import_plan(&query_manager, plan).expect("commit import"); + assert!(committed.workspaces.is_empty()); + assert_eq!(committed.http_requests.len(), 2); + assert_eq!( + query_manager + .connect() + .get_workspace(&destination.id) + .expect("get destination after commit"), + destination + ); + } + + #[test] + fn environment_collisions_are_explicit_and_do_not_overwrite() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let mut resources = imported_resources(); + resources.environments.extend([ + Environment { + id: "ev_second_base".to_string(), + model: "environment".to_string(), + workspace_id: "wk_source".to_string(), + name: "Second Base".to_string(), + parent_model: "workspace".to_string(), + ..Default::default() + }, + Environment { + id: "ev_folder_one".to_string(), + model: "environment".to_string(), + workspace_id: "wk_source".to_string(), + name: "Folder One".to_string(), + parent_model: "folder".to_string(), + parent_id: Some("fl_source".to_string()), + ..Default::default() + }, + Environment { + id: "ev_folder_two".to_string(), + model: "environment".to_string(), + workspace_id: "wk_source".to_string(), + name: "Folder Two".to_string(), + parent_model: "folder".to_string(), + parent_id: Some("fl_source".to_string()), + ..Default::default() + }, + ]); + + let plan = plan_import_resources( + &query_manager, + "Yaak".to_string(), + ImportDestination::NewWorkspace, + resources, + ) + .expect("plan import"); + + assert_eq!( + plan.resources + .environments + .iter() + .filter(|v| v.resource.parent_model == "workspace") + .count(), + 1 + ); + assert_eq!( + plan.resources + .environments + .iter() + .filter(|v| v.resource.parent_model == "folder") + .count(), + 1 + ); + assert_eq!(plan.warnings.len(), 2); + } + + #[test] + fn importer_id_conventions_all_flow_through_the_same_planner() { + let (query_manager, _blob_manager, _rx) = + yaak_models::init_in_memory().expect("initialize database"); + let destination = destination_workspace(); + query_manager + .connect() + .upsert_workspace(&destination, &UpdateSource::Import) + .expect("create destination"); + let resources = ImportResources { + workspaces: vec![ + Workspace { + id: "GENERATE_ID::WORKSPACE_0".to_string(), + model: "workspace".to_string(), + name: "Generated ID Importer".to_string(), + ..Default::default() + }, + Workspace { + id: "wk_exported".to_string(), + model: "workspace".to_string(), + name: "Stable ID Importer".to_string(), + ..Default::default() + }, + ], + http_requests: [ + "GENERATE_ID::WORKSPACE_0", + "wk_exported", + "CURRENT_WORKSPACE", + ] + .into_iter() + .enumerate() + .map(|(index, workspace_id)| HttpRequest { + id: format!("GENERATE_ID::HTTP_REQUEST_{index}"), + model: "http_request".to_string(), + workspace_id: workspace_id.to_string(), + name: format!("Request {index}"), + method: "GET".to_string(), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + + let plan = plan_import_resources( + &query_manager, + "Compatibility".to_string(), + ImportDestination::CurrentWorkspace { + workspace_id: destination.id.clone(), + folder_id: None, + }, + resources, + ) + .expect("plan import"); + + assert!(plan.resources.workspaces.is_empty()); + assert!( + plan.resources.http_requests.iter().all(|v| v.resource.workspace_id == destination.id) + ); + assert_eq!( + plan.resources + .http_requests + .iter() + .map(|v| v.resource.id.as_str()) + .collect::>() + .len(), + 3 + ); + } + + #[test] + fn commit_rolls_back_every_resource_when_a_late_write_fails() { + let dir = tempfile::tempdir().expect("create temp directory"); + let db_path = dir.path().join("models.sqlite"); + let blob_path = dir.path().join("blobs.sqlite"); + let (query_manager, _blob_manager, _rx) = + yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database"); + let plan = plan_import_resources( + &query_manager, + "OpenAPI".to_string(), + ImportDestination::NewWorkspace, + imported_resources(), + ) + .expect("plan import"); + let workspace_id = plan.resources.workspaces[0].resource.id.clone(); + let environment_id = plan.resources.environments[0].resource.id.clone(); + + let connection = rusqlite::Connection::open(&db_path).expect("open test database"); + connection + .execute_batch(&format!( + "CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \ + WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;" + )) + .expect("install failure trigger"); + drop(connection); + + assert!(commit_import_plan(&query_manager, plan).is_err()); + let db = query_manager.connect(); + assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back"); + assert!(db.get_environment(&environment_id).is_err(), "environment must not exist"); + } +} diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts index f3eb5b29..4075aedd 100644 --- a/packages/platform/src/web/commands.ts +++ b/packages/platform/src/web/commands.ts @@ -261,6 +261,7 @@ const DECLINED: Partial, environments: Array, folders: Array, httpRequests: Array, grpcRequests: Array, websocketRequests: Array, }; -export type ImportResponse = { resources: ImportResources, }; +export type ImportResponse = { importer: string, resources: ImportResources, }; export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index b61761d3..6f18bba0 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -167,6 +167,7 @@ export class PluginInstance { if (reply != null) { const replyPayload: InternalEventPayload = { type: "import_response", + importer: this.#mod.importer.name, resources: reply.resources as ImportResources, }; this.#sendPayload(context, replyPayload, replyId);