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:
Gregory Schier
2026-09-05 21:16:29 -07:00
parent 19a43e3785
commit b08b3277da
12 changed files with 407 additions and 36 deletions
+51
View File
@@ -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";
+17
View File
@@ -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 {
+62 -8
View File
@@ -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();