diff --git a/crates-tauri/yaak-app-client/src/rpc_ext.rs b/crates-tauri/yaak-app-client/src/rpc_ext.rs index 4d1f5f26..b9093af4 100644 --- a/crates-tauri/yaak-app-client/src/rpc_ext.rs +++ b/crates-tauri/yaak-app-client/src/rpc_ext.rs @@ -37,8 +37,8 @@ use yaak_grpc::ServiceDefinition; use yaak_models::blob_manager::BlobManager; use yaak_models::models::{ GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, - HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, - WorkspaceMeta, + HttpResponseEvent, ImportSource, ModelVersion, Plugin, RequestVersionComparison, Settings, + WebsocketConnection, WebsocketEvent, WorkspaceMeta, }; use yaak_models::query_manager::QueryManager; use yaak_models::util::{BatchUpsertResult, ImportPlan}; @@ -653,6 +653,18 @@ async fn models_duplicate(ctx: ClientCtx, req: ModelsDuplicateReq Ok(yaak_commands::models::models_duplicate(ctx, req).await?) } +async fn models_snapshot_request(ctx: ClientCtx, req: ModelsSnapshotRequestReq) -> Result { + Ok(yaak_commands::models::models_snapshot_request(ctx, req).await?) +} + +async fn models_request_version(ctx: ClientCtx, req: ModelsRequestVersionReq) -> Result { + Ok(yaak_commands::models::models_request_version(ctx, req).await?) +} + +async fn models_restore_request_version(ctx: ClientCtx, req: ModelsRestoreRequestVersionReq) -> Result { + Ok(yaak_commands::models::models_restore_request_version(ctx, req).await?) +} + async fn models_websocket_events(ctx: ClientCtx, req: ModelsWebsocketEventsReq) -> Result> { Ok(yaak_commands::models::models_websocket_events(ctx, req).await?) } diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts index aa5fa233..618e1213 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts @@ -138,6 +138,10 @@ export type GrpcConnection = { state: GrpcConnectionState; trailers: { [key in string]?: string }; url: string; + /** + * The request version this connection was opened from, when one was captured. + */ + versionId: string | null; }; export type GrpcConnectionState = "initialized" | "connected" | "closed"; @@ -242,6 +246,10 @@ export type HttpResponse = { state: HttpResponseState; url: string; version: string | null; + /** + * The request version this response was sent from, when one was captured. + */ + versionId: string | null; }; export type HttpResponseEvent = { @@ -331,17 +339,6 @@ export type ImportSource = { lastImportedAt: string; }; -export type ImportSourceResource = { - model: "import_source_resource"; - createdAt: string; - updatedAt: string; - importSourceId: string; - sourceKey: string; - modelType: string; - modelId: string; - snapshot: string; -}; - export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; @@ -358,6 +355,28 @@ export type KeyValue = { value: string; }; +export type ModelVersion = { + model: "model_version"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + /** + * The `model` field of the versioned model, eg. `http_request`. + */ + modelType: string; + modelId: string; + contentHash: string; + document: Record; + reason: ModelVersionReason; +}; + +/** + * Why a version was captured. Not a UI label — the frontend decides how to + * phrase these — but it is what makes a history readable when debugging. + */ +export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual"; + export type Plugin = { model: "plugin"; id: string; @@ -385,6 +404,23 @@ export type ProxySetting = export type ProxySettingAuth = { user: string; password: string }; +/** + * One version, next to the request as it stands now. + * + * Both halves come from the same place so they are guaranteed comparable: the + * frontend renders them side by side, and `differs` is the same content-hash + * comparison the backend uses everywhere else rather than a second opinion + * formed in TypeScript. + */ +export type RequestVersionComparison = { + version: ModelVersion; + /** + * The live request's editable content, in the same shape as the version's document. + */ + currentDocument: Record; + differs: boolean; +}; + export type Settings = { model: "settings"; id: string; @@ -449,6 +485,10 @@ export type WebsocketConnection = { state: WebsocketConnectionState; status: number; url: string; + /** + * The request version this connection was opened from, when one was captured. + */ + versionId: string | null; }; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; diff --git a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts index 07841251..4a8dcf4d 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_rpc.ts @@ -3,7 +3,7 @@ import type { PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse } f import type { CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest, CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, JsonPrimitive, RenderPurpose } from "./gen_events"; import type { BranchDeleteResult, CloneResult, GitBranchInfo, GitCommit, GitFileDiff, GitRemote, GitStatusSummary, GitWorktreeStatus, PullResult, PushResult } from "./gen_git"; import type { ServiceDefinition } from "./gen_grpc"; -import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models"; +import type { AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, HttpResponseEvent, ImportSource, ModelVersion, ModelVersionReason, Plugin, RequestVersionComparison, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta } from "./gen_models"; import type { PluginMetadata } from "./gen_search"; import type { SyncOp } from "./gen_sync"; import type { BatchUpsertResult, ImportDestination, ImportPlan } from "./gen_util"; @@ -246,6 +246,12 @@ export type ModelsGetSettingsReq = Record; export type ModelsGrpcEventsReq = { connectionId: string, }; +export type ModelsRequestVersionReq = { versionId: string, }; + +export type ModelsRestoreRequestVersionReq = { versionId: string, }; + +export type ModelsSnapshotRequestReq = { requestId: string, reason: ModelVersionReason, }; + export type ModelsUpsertGraphqlIntrospectionReq = { requestId: string, workspaceId: string, content: string | null, }; export type ModelsUpsertReq = { model: AnyModel, }; @@ -254,6 +260,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, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], 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_list_import_sources: [CmdListImportSourcesReq, Array], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array], 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_snapshot_request: [ModelsSnapshotRequestReq, ModelVersion], models_request_version: [ModelsRequestVersionReq, RequestVersionComparison], models_restore_request_version: [ModelsRestoreRequestVersionReq, 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/src/lib.rs b/crates/common/yaak-rpc-schema/src/lib.rs index 776f91cf..62ff1c3d 100644 --- a/crates/common/yaak-rpc-schema/src/lib.rs +++ b/crates/common/yaak-rpc-schema/src/lib.rs @@ -21,8 +21,8 @@ use yaak_git::{ use yaak_grpc::ServiceDefinition; use yaak_models::models::{ AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse, - HttpResponseEvent, ImportSource, Plugin, Settings, WebsocketConnection, WebsocketEvent, - WorkspaceMeta, + HttpResponseEvent, ImportSource, ModelVersion, ModelVersionReason, Plugin, + RequestVersionComparison, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta, }; use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan}; use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse}; @@ -534,6 +534,28 @@ pub struct ModelsDuplicateReq { pub model_id: String, } +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ModelsSnapshotRequestReq { + pub request_id: String, + pub reason: ModelVersionReason, +} + +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ModelsRequestVersionReq { + pub version_id: String, +} + +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_rpc.ts")] +pub struct ModelsRestoreRequestVersionReq { + pub version_id: String, +} + #[derive(Debug, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "gen_rpc.ts")] @@ -981,6 +1003,9 @@ macro_rules! with_commands { models_upsert(ModelsUpsertReq) -> String, models_delete(ModelsDeleteReq) -> String, models_duplicate(ModelsDuplicateReq) -> String, + models_snapshot_request(ModelsSnapshotRequestReq) -> ModelVersion, + models_request_version(ModelsRequestVersionReq) -> RequestVersionComparison, + models_restore_request_version(ModelsRestoreRequestVersionReq) -> String, models_websocket_events(ModelsWebsocketEventsReq) -> Vec, models_grpc_events(ModelsGrpcEventsReq) -> Vec, models_get_settings(ModelsGetSettingsReq) -> Settings, diff --git a/crates/yaak-commands/src/models.rs b/crates/yaak-commands/src/models.rs index 86ec67d7..85784f95 100644 --- a/crates/yaak-commands/src/models.rs +++ b/crates/yaak-commands/src/models.rs @@ -4,9 +4,10 @@ use crate::error::Result; use crate::host::{Host, PluginHost}; use yaak_models::models::{ - AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, Settings, WebsocketEvent, - WorkspaceMeta, + AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequestHeader, ModelVersion, + RequestVersionComparison, Settings, WebsocketEvent, WorkspaceMeta, }; +use yaak_models::versions::version_document; use yaak_models::queries::workspaces::default_headers; use yaak_rpc_schema::*; @@ -45,6 +46,42 @@ pub async fn models_duplicate(host: H, req: ModelsDuplicateReq) -> Resu })?) } +/// Capture the request's current content, from an edit-session boundary the +/// frontend can see: switching away, losing focus, closing, or falling idle. +/// +/// The frontend does not track whether anything actually changed — versions are +/// content-addressed, so an unchanged request returns the version it already +/// had and the trigger code stays a one-liner. +pub async fn models_snapshot_request( + host: H, + req: ModelsSnapshotRequestReq, +) -> Result { + Ok(host.db().snapshot_request_by_id(&req.request_id, req.reason)?) +} + +/// A version and the live request side by side, for the diff and for deciding +/// whether there is anything worth offering. +pub async fn models_request_version( + host: H, + req: ModelsRequestVersionReq, +) -> Result { + let db = host.db(); + let version = db.get_model_version(&req.version_id)?; + let current_document = version_document(&db.get_any_request(&version.model_id)?.to_value()?)?; + let differs = !db.request_matches_version(&version)?; + Ok(RequestVersionComparison { version, current_document, differs }) +} + +/// Returns the id of the request that was restored. +pub async fn models_restore_request_version( + host: H, + req: ModelsRestoreRequestVersionReq, +) -> Result { + let source = host.update_source(); + let restored = host.db().restore_request_version(&req.version_id, &source)?; + Ok(restored.id().to_string()) +} + pub async fn models_websocket_events( host: H, req: ModelsWebsocketEventsReq, diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index 5965fecd..372a3523 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -139,6 +139,10 @@ export type GrpcConnection = { state: GrpcConnectionState; trailers: { [key in string]?: string }; url: string; + /** + * The request version this connection was opened from, when one was captured. + */ + versionId: string | null; }; export type GrpcConnectionState = "initialized" | "connected" | "closed"; @@ -243,6 +247,10 @@ export type HttpResponse = { state: HttpResponseState; url: string; version: string | null; + /** + * The request version this response was sent from, when one was captured. + */ + versionId: string | null; }; export type HttpResponseEvent = { @@ -382,6 +390,28 @@ export type ModelPayload = { change: ModelChangeEvent; }; +export type ModelVersion = { + model: "model_version"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + /** + * The `model` field of the versioned model, eg. `http_request`. + */ + modelType: string; + modelId: string; + contentHash: string; + document: Record; + reason: ModelVersionReason; +}; + +/** + * Why a version was captured. Not a UI label — the frontend decides how to + * phrase these — but it is what makes a history readable when debugging. + */ +export type ModelVersionReason = "send" | "switch" | "idle" | "restore" | "manual"; + export type ParentAuthentication = { authentication: Record; authenticationType: string | null; @@ -425,6 +455,23 @@ export type ProxySetting = export type ProxySettingAuth = { user: string; password: string }; +/** + * One version, next to the request as it stands now. + * + * Both halves come from the same place so they are guaranteed comparable: the + * frontend renders them side by side, and `differs` is the same content-hash + * comparison the backend uses everywhere else rather than a second opinion + * formed in TypeScript. + */ +export type RequestVersionComparison = { + version: ModelVersion; + /** + * The live request's editable content, in the same shape as the version's document. + */ + currentDocument: Record; + differs: boolean; +}; + export type Settings = { model: "settings"; id: string; @@ -488,6 +535,10 @@ export type WebsocketConnection = { state: WebsocketConnectionState; status: number; url: string; + /** + * The request version this connection was opened from, when one was captured. + */ + versionId: string | null; }; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 1ecf0309..34365ee2 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -3285,6 +3285,23 @@ impl UpsertModelInfo for ModelVersion { } } +/// One version, next to the request as it stands now. +/// +/// Both halves come from the same place so they are guaranteed comparable: the +/// frontend renders them side by side, and `differs` is the same content-hash +/// comparison the backend uses everywhere else rather than a second opinion +/// formed in TypeScript. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_models.ts")] +pub struct RequestVersionComparison { + pub version: ModelVersion, + /// The live request's editable content, in the same shape as the version's document. + #[ts(type = "Record")] + pub current_document: Value, + pub differs: bool, +} + /// Only used as a `from_row` fallback for an unparseable settings column. The /// value a *new* model gets comes from that model's `Default` impl. fn default_request_message_size_setting() -> InheritedIntSetting { diff --git a/crates/yaak-models/src/versions.rs b/crates/yaak-models/src/versions.rs index 1864d3fe..433e98c3 100644 --- a/crates/yaak-models/src/versions.rs +++ b/crates/yaak-models/src/versions.rs @@ -33,13 +33,46 @@ pub fn version_document(model: &T) -> Result { } /// The hash a version is addressed by. -/// -/// Canonical by construction: `serde_json::Map` is a `BTreeMap` here, so -/// serialization already visits keys in sorted order and two documents that -/// differ only in key order hash the same. pub fn content_hash(document: &Value) -> Result { - let canonical = serde_json::to_vec(document)?; - Ok(hex::encode(Sha256::digest(&canonical))) + let mut canonical = String::new(); + write_canonical(document, &mut canonical); + Ok(hex::encode(Sha256::digest(canonical.as_bytes()))) +} + +/// Serialize with object keys in sorted order. +/// +/// Plain `to_string` would not do: whether `serde_json::Map` preserves +/// insertion order or sorts is a workspace-wide feature decision, and a +/// document read back from SQLite has whatever order it was written in. Sorting +/// here makes the hash depend on the content and nothing else, in every build. +fn write_canonical(value: &Value, out: &mut String) { + match value { + Value::Object(map) => { + let mut keys = map.keys().collect::>(); + keys.sort_unstable(); + out.push('{'); + for (i, key) in keys.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(&Value::String(key.clone()), out); + out.push(':'); + write_canonical(&map[key], out); + } + out.push('}'); + } + Value::Array(items) => { + out.push('['); + for (i, item) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(item, out); + } + out.push(']'); + } + scalar => out.push_str(&scalar.to_string()), + } } /// Lay a version's document back over a live model. @@ -142,8 +175,10 @@ mod tests { ); } - /// The hash has to survive being written to and read back from the - /// database, which does not preserve key order. + /// The hash has to survive a round trip through SQLite, which stores the + /// document as text and hands back whatever order it was written in. It + /// also has to survive `serde_json`'s `preserve_order` feature being on in + /// one build of the workspace and off in another. #[test] fn key_order_does_not_change_the_hash() { let a: Value = serde_json::from_str(r#"{"url":"a","method":"GET"}"#).unwrap(); @@ -151,6 +186,25 @@ mod tests { assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap()); } + #[test] + fn key_order_does_not_change_the_hash_when_nested() { + let a: Value = + serde_json::from_str(r#"{"body":{"text":"x","type":"json"},"headers":[{"a":1,"b":2}]}"#) + .unwrap(); + let b: Value = + serde_json::from_str(r#"{"headers":[{"b":2,"a":1}],"body":{"type":"json","text":"x"}}"#) + .unwrap(); + assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap()); + } + + /// Sorting keys must not make different documents collide. + #[test] + fn array_order_still_changes_the_hash() { + let a: Value = serde_json::from_str(r#"{"headers":[{"n":"a"},{"n":"b"}]}"#).unwrap(); + let b: Value = serde_json::from_str(r#"{"headers":[{"n":"b"},{"n":"a"}]}"#).unwrap(); + assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap()); + } + #[test] fn applying_a_document_keeps_the_live_model_identity() { let live = serde_json::to_value(request()).unwrap(); diff --git a/crates/yaak-plugins/bindings/gen_models.ts b/crates/yaak-plugins/bindings/gen_models.ts index 1f09c532..d7eee4b8 100644 --- a/crates/yaak-plugins/bindings/gen_models.ts +++ b/crates/yaak-plugins/bindings/gen_models.ts @@ -11,6 +11,7 @@ export type AnyModel = | HttpRequest | HttpResponse | HttpResponseEvent + | ImportSource | KeyValue | Plugin | Settings @@ -137,6 +138,10 @@ export type GrpcConnection = { state: GrpcConnectionState; trailers: { [key in string]?: string }; url: string; + /** + * The request version this connection was opened from, when one was captured. + */ + versionId: string | null; }; export type GrpcConnectionState = "initialized" | "connected" | "closed"; @@ -241,6 +246,10 @@ export type HttpResponse = { state: HttpResponseState; url: string; version: string | null; + /** + * The request version this response was sent from, when one was captured. + */ + versionId: string | null; }; export type HttpResponseEvent = { @@ -318,6 +327,18 @@ export type HttpUrlParameter = { export type HttpVersion = "auto" | "http1" | "http2"; +export type ImportSource = { + model: "import_source"; + id: string; + createdAt: string; + updatedAt: string; + workspaceId: string; + importer: string; + origin: string; + originLabel: string; + lastImportedAt: string; +}; + export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; @@ -417,6 +438,10 @@ export type WebsocketConnection = { state: WebsocketConnectionState; status: number; url: string; + /** + * The request version this connection was opened from, when one was captured. + */ + versionId: string | null; }; export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed"; diff --git a/crates/yaak-wasm/src/lib.rs b/crates/yaak-wasm/src/lib.rs index 45704162..3feeceb9 100644 --- a/crates/yaak-wasm/src/lib.rs +++ b/crates/yaak-wasm/src/lib.rs @@ -32,12 +32,13 @@ use yaak_models::blob_manager::{BlobManager, BodyChunk}; use yaak_models::cookies::apply_cookie_changes; use yaak_models::models::{ AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData, - HttpSendSettings, + HttpSendSettings, ModelVersionReason, RequestVersionComparison, }; use yaak_models::models_ops; use yaak_models::query_manager::QueryManager; use yaak_models::render::render_http_request; use yaak_models::util::{ModelPayload, UpdateSource}; +use yaak_models::versions::version_document; use yaak_templates::{RenderOptions, TemplateCallback}; /// Names inside the VFS, not paths on any disk. Two files because the desktop @@ -218,6 +219,19 @@ struct UpsertIntrospectionReq { content: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SnapshotRequestReq { + request_id: String, + reason: ModelVersionReason, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct VersionIdReq { + version_id: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ResponseIdReq { @@ -319,6 +333,37 @@ fn dispatch( to_json(id) } + "models_snapshot_request" => { + let req: SnapshotRequestReq = from_js(payload)?; + to_json( + host.queries + .connect() + .snapshot_request_by_id(&req.request_id, req.reason) + .map_err(js_error)?, + ) + } + + "models_request_version" => { + let req: VersionIdReq = from_js(payload)?; + let db = host.queries.connect(); + let version = db.get_model_version(&req.version_id).map_err(js_error)?; + let request = db.get_any_request(&version.model_id).map_err(js_error)?; + let current_document = + version_document(&request.to_value().map_err(js_error)?).map_err(js_error)?; + let differs = !db.request_matches_version(&version).map_err(js_error)?; + to_json(RequestVersionComparison { version, current_document, differs }) + } + + "models_restore_request_version" => { + let req: VersionIdReq = from_js(payload)?; + let restored = host + .queries + .connect() + .restore_request_version(&req.version_id, source) + .map_err(js_error)?; + to_json(restored.id().to_string()) + } + "models_get_settings" => to_json(host.queries.connect().get_settings()), "models_get_graphql_introspection" => { diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts index a8d75094..d53cf567 100644 --- a/packages/platform/src/web/commands.ts +++ b/packages/platform/src/web/commands.ts @@ -63,6 +63,10 @@ const HANDLERS: Partial> = { db.rpc("models_get_graphql_introspection", payload), models_upsert_graphql_introspection: (payload, db) => db.rpc("models_upsert_graphql_introspection", payload), + models_snapshot_request: (payload, db) => db.rpc("models_snapshot_request", payload), + models_request_version: (payload, db) => db.rpc("models_request_version", payload), + models_restore_request_version: (payload, db) => + db.rpc("models_restore_request_version", payload), models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload), models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload), cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload), @@ -76,7 +80,12 @@ const HANDLERS: Partial> = { cmd_send_http_request: (payload, db) => { const requestId = str(payload, "requestId"); if (requestId == null) throw new Error("cmd_send_http_request needs a requestId"); - return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId")); + return sendHttpRequest( + db, + requestId, + str(payload, "environmentId"), + str(payload, "cookieJarId"), + ); }, /* -------------------------------- app ---------------------------------- */ @@ -262,10 +271,16 @@ const DECLINED: Partial { + try { + const version = await db.rpc("models_snapshot_request", { + requestId, + reason: "send", + }); + return version.id; + } catch (err) { + // History is not worth failing a send over + console.warn("Failed to snapshot request version", err); + return undefined; + } +} + async function runSend( db: WorkerConnection, response: ResponseWriter, @@ -315,8 +345,16 @@ class TimelineWriter { * yaak-models), so an edit made while the send was in flight survives rather * than being written over by the send's stale snapshot. */ -async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise { - await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies }); +async function persistCookies( + db: WorkerConnection, + jar: CookieJar, + cookies: Cookie[], +): Promise { + await db.rpc("web_persist_send_cookies", { + cookieJarId: jar.id, + before: jar.cookies, + after: cookies, + }); } /**