mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-07 18:31:49 +02:00
feat(rpc): expose snapshot, compare and restore for request versions
Three commands, all host-independent so the browser build answers them from the same model layer as the desktop: - models_snapshot_request, for the edit-session boundaries only the frontend can see. It takes a reason and nothing else — the caller does not decide whether anything changed, because content addressing already has. - models_request_version, which returns a version and the live request's content together so they are guaranteed comparable, plus the same content-hash verdict the backend uses rather than a second opinion formed in TypeScript. - models_restore_request_version. The browser host also snapshots before its own send, since its send pipeline is in TypeScript rather than in the shared crate. Bindings regenerated with CI's `cargo test --all --features yaak-app-client/wry`, which also drops a stale ImportSourceResource from the rpc-schema copy and adds a missing ImportSource to the plugins copy.
This commit is contained in:
@@ -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<R: Runtime>(ctx: ClientCtx<R>, req: ModelsDuplicateReq
|
||||
Ok(yaak_commands::models::models_duplicate(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_snapshot_request<R: Runtime>(ctx: ClientCtx<R>, req: ModelsSnapshotRequestReq) -> Result<ModelVersion> {
|
||||
Ok(yaak_commands::models::models_snapshot_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_request_version<R: Runtime>(ctx: ClientCtx<R>, req: ModelsRequestVersionReq) -> Result<RequestVersionComparison> {
|
||||
Ok(yaak_commands::models::models_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_restore_request_version<R: Runtime>(ctx: ClientCtx<R>, req: ModelsRestoreRequestVersionReq) -> Result<String> {
|
||||
Ok(yaak_commands::models::models_restore_request_version(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn models_websocket_events<R: Runtime>(ctx: ClientCtx<R>, req: ModelsWebsocketEventsReq) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(yaak_commands::models::models_websocket_events(ctx, req).await?)
|
||||
}
|
||||
|
||||
+51
-11
@@ -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<string, any>;
|
||||
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<string, any>;
|
||||
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";
|
||||
|
||||
+8
-2
File diff suppressed because one or more lines are too long
@@ -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<WebsocketEvent>,
|
||||
models_grpc_events(ModelsGrpcEventsReq) -> Vec<GrpcEvent>,
|
||||
models_get_settings(ModelsGetSettingsReq) -> Settings,
|
||||
|
||||
@@ -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<H: Host>(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<H: Host>(
|
||||
host: H,
|
||||
req: ModelsSnapshotRequestReq,
|
||||
) -> Result<ModelVersion> {
|
||||
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<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRequestVersionReq,
|
||||
) -> Result<RequestVersionComparison> {
|
||||
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<H: Host>(
|
||||
host: H,
|
||||
req: ModelsRestoreRequestVersionReq,
|
||||
) -> Result<String> {
|
||||
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<H: Host>(
|
||||
host: H,
|
||||
req: ModelsWebsocketEventsReq,
|
||||
|
||||
+51
@@ -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<string, any>;
|
||||
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<string, any>;
|
||||
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<string, any>;
|
||||
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";
|
||||
|
||||
@@ -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<string, any>")]
|
||||
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 {
|
||||
|
||||
@@ -33,13 +33,46 @@ pub fn version_document<T: Serialize>(model: &T) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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::<Vec<_>>();
|
||||
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();
|
||||
|
||||
+25
@@ -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";
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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" => {
|
||||
|
||||
@@ -63,6 +63,10 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
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<Record<AppCmd, Handler>> = {
|
||||
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<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
|
||||
cmd_ws_delete_connections: [
|
||||
"WebSocket requests aren't available in the browser yet",
|
||||
"websocket",
|
||||
],
|
||||
|
||||
// Anything that needs files the page can't reach.
|
||||
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
|
||||
cmd_import_data: [
|
||||
"Importing from a file needs a filesystem, which a browser tab has no",
|
||||
"localFiles",
|
||||
],
|
||||
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
|
||||
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
|
||||
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
|
||||
@@ -298,8 +313,14 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_function_config: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_template_tokens_to_string: [
|
||||
"Template functions come from plugins, which this host doesn't run",
|
||||
"plugins",
|
||||
],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
HttpResponse,
|
||||
HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
ModelVersion,
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { Frame, SendRequest } from "@yaakapp-internal/web";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
@@ -71,7 +72,13 @@ export async function sendHttpRequest(
|
||||
// a failure to render or to reach the server lands in the response pane as
|
||||
// that response's error rather than as a toast that names no request.
|
||||
const workspaceId = await workspaceIdOfRequest(db, requestId);
|
||||
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
|
||||
const versionId = await snapshotRequestVersion(db, requestId);
|
||||
const response = new ResponseWriter(db, {
|
||||
model: "http_response",
|
||||
requestId,
|
||||
workspaceId,
|
||||
versionId,
|
||||
});
|
||||
await response.create();
|
||||
|
||||
const cancel = new AbortController();
|
||||
@@ -88,6 +95,29 @@ export async function sendHttpRequest(
|
||||
return response.current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture what is about to be sent, so the response can offer it back later.
|
||||
* The desktop does this inside its send pipeline; this host's pipeline is here,
|
||||
* so this is where it goes. Versions are content-addressed, so repeated sends
|
||||
* of an unchanged request all point at the same one.
|
||||
*/
|
||||
async function snapshotRequestVersion(
|
||||
db: WorkerConnection,
|
||||
requestId: string,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const version = await db.rpc<ModelVersion>("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<void> {
|
||||
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<void> {
|
||||
await db.rpc("web_persist_send_cookies", {
|
||||
cookieJarId: jar.id,
|
||||
before: jar.cookies,
|
||||
after: cookies,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user