fix(import): treat a pair's row ID as identity, not content

Headers, URL parameters, and environment variables each carry an `id`
that identifies the row to the pair editor. Importers leave it empty and
the editor stamps one in the first time it touches a resource, so merely
opening an imported request made the next import report it as locally
edited. `comparable` now drops `id` at every level rather than only the
top, since a row ID says no more about content than the model's own ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-09-02 11:31:12 -07:00
co-authored by Claude Opus 5
parent 6796569466
commit 64b9479deb
+71 -5
View File
@@ -1005,17 +1005,33 @@ enum KeyStatus<'a> {
/// The deprecated environment `base` flag mirrors `parentModel`, which is compared already.
/// Importers number `sortPriority` from source order, so comparing it would turn one insertion
/// into an update of everything after it.
fn comparable(mut value: Value) -> Value {
fn comparable(value: Value) -> Value {
let mut value = strip_ids(value);
if let Some(object) = value.as_object_mut() {
for field in
["id", "model", "workspaceId", "createdAt", "updatedAt", "base", "sortPriority"]
{
for field in ["model", "workspaceId", "createdAt", "updatedAt", "base", "sortPriority"] {
object.remove(field);
}
}
value
}
/// A header, parameter, or variable carries an `id` that identifies its row to the editor rather
/// than anything about its content, and the editor fills those in the first time it touches a
/// resource. Dropping every `id` keeps that from reading as a local edit.
fn strip_ids(value: Value) -> Value {
match value {
Value::Object(object) => Value::Object(
object
.into_iter()
.filter(|(key, _)| key != "id")
.map(|(key, value)| (key, strip_ids(value)))
.collect(),
),
Value::Array(items) => Value::Array(items.into_iter().map(strip_ids).collect()),
other => other,
}
}
const CONTENT_HASH_VERSION: &str = "v1:";
/// Identifies a resource's content well enough to tell "changed since the last import" from
@@ -1920,11 +1936,18 @@ mod tests {
}
fn first_import(query_manager: &QueryManager) -> BatchUpsertResult {
first_import_with(query_manager, imported_resources())
}
fn first_import_with(
query_manager: &QueryManager,
resources: ImportResources,
) -> BatchUpsertResult {
let plan = plan_import_resources(
query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
resources,
Some(importer_keys()),
Some(linked_origin()),
)
@@ -2563,6 +2586,49 @@ mod tests {
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
}
#[test]
fn filling_in_editor_row_ids_is_not_an_edit() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.http_requests[0].headers = vec![HttpRequestHeader {
enabled: true,
name: "Accept".to_string(),
value: "application/json".to_string(),
id: None,
}];
let committed = first_import_with(&query_manager, resources.clone());
let workspace_id = committed.workspaces[0].id.clone();
let root_id = committed
.http_requests
.iter()
.find(|r| r.name == "Root Request")
.expect("root request")
.id
.clone();
// Opening the request in the editor stamps a row ID onto every header it renders.
{
let db = query_manager.connect();
let root = db.get_http_request(&root_id).expect("get root");
let headers = root
.headers
.iter()
.map(|h| HttpRequestHeader { id: Some("hd_generated".to_string()), ..h.clone() })
.collect();
db.upsert_http_request(&HttpRequest { headers, ..root }, &UpdateSource::Background)
.expect("stamp row ids");
}
let plan = replan(&query_manager, &workspace_id, resources);
assert_eq!(
item_by_name(&plan, "Root Request").action,
ImportPlanAction::Unchanged,
"a row ID is not content: {:?}",
item_by_name(&plan, "Root Request").changed_fields,
);
}
#[test]
fn reordering_the_source_document_changes_nothing() {
let (query_manager, _blob_manager, _rx) =