Preserve source identity ownership across prepared model commands

This commit is contained in:
2026-09-05 22:25:51 +03:00
parent f6d04ee4b3
commit 86bf36bf4f
5 changed files with 657 additions and 23 deletions
+101 -13
View File
@@ -1,6 +1,6 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use thiserror::Error;
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
@@ -93,6 +93,65 @@ pub struct Session {
pub struct PreparedCommand {
pub project: Project,
pub selected: Option<Id>,
/// Complete correspondence from surviving original identities to candidate
/// identities, including unchanged objects. Newly created objects have no
/// origin. Replacement establishes correspondence by common original UUIDs.
pub identity_map: BTreeMap<Id, Id>,
}
/// Track actual identity operations, not aliases or structural similarity.
/// The reverse map composes renames without scanning every original object.
struct IdentityProvenance {
original: BTreeSet<Id>,
current_to_original: BTreeMap<Id, Id>,
}
impl IdentityProvenance {
fn new(project: &Project) -> Self {
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
let current_to_original = original.iter().map(|id| (*id, *id)).collect();
Self {
original,
current_to_original,
}
}
fn replace(&mut self, project: &Project) {
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
self.current_to_original = self
.original
.intersection(&present)
.map(|id| (*id, *id))
.collect();
}
fn retain_present(&mut self, project: &Project) {
if !self.current_to_original.is_empty() {
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
self.current_to_original
.retain(|id, _| present.contains(id));
}
}
fn into_map(self) -> BTreeMap<Id, Id> {
self.current_to_original
.into_iter()
.map(|(current, original)| (original, current))
.collect()
}
}
fn reidentify_tracked(
project: &mut Project,
old: Id,
new: Id,
provenance: &mut IdentityProvenance,
) -> Result<(), ModelError> {
reidentify(project, old, new)?;
if let Some(original) = provenance.current_to_original.remove(&old) {
provenance.current_to_original.insert(new, original);
}
Ok(())
}
pub fn prepare_command(
@@ -101,12 +160,21 @@ pub fn prepare_command(
) -> Result<PreparedCommand, ModelError> {
let mut candidate = project.clone();
let mut pending_deletions = BTreeSet::new();
let selected = apply_command(&mut candidate, command, 0, &mut pending_deletions)?;
let mut provenance = IdentityProvenance::new(project);
let selected = apply_command(
&mut candidate,
command,
0,
&mut pending_deletions,
&mut provenance,
)?;
crate::references::finish_deletions(&mut candidate, &pending_deletions);
provenance.retain_present(&candidate);
validate_candidate(&candidate)?;
Ok(PreparedCommand {
project: candidate,
selected,
identity_map: provenance.into_map(),
})
}
@@ -171,6 +239,7 @@ impl Session {
let PreparedCommand {
project: candidate,
selected,
..
} = prepare_command(&self.project, &command)?;
if candidate != self.project {
self.past
@@ -197,8 +266,15 @@ pub fn validate_candidate(p: &Project) -> Result<(), ModelError> {
pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project, ModelError> {
let mut candidate = project.clone();
let mut pending_deletions = BTreeSet::new();
let mut provenance = IdentityProvenance::new(project);
for command in commands {
apply_command(&mut candidate, command, 0, &mut pending_deletions)?;
apply_command(
&mut candidate,
command,
0,
&mut pending_deletions,
&mut provenance,
)?;
}
crate::references::finish_deletions(&mut candidate, &pending_deletions);
validate_candidate(&candidate)?;
@@ -266,6 +342,7 @@ fn apply_command(
c: &Command,
depth: usize,
pending_deletions: &mut BTreeSet<Id>,
provenance: &mut IdentityProvenance,
) -> Result<Option<Id>, ModelError> {
if depth > 64 {
return Err(ModelError(
@@ -276,12 +353,13 @@ fn apply_command(
Command::Replace(value) => {
*p = value.clone();
pending_deletions.clear();
provenance.replace(p);
None
}
Command::Batch(commands) => {
let mut selected = None;
for command in commands {
let id = apply_command(p, command, depth + 1, pending_deletions)?;
let id = apply_command(p, command, depth + 1, pending_deletions, provenance)?;
if id.is_some() {
selected = id;
}
@@ -342,7 +420,7 @@ fn apply_command(
let mut e = value.clone();
e.id = *old;
p.elements.insert(*old, e);
reidentify(p, *old, value.id)?;
reidentify_tracked(p, *old, value.id, provenance)?;
if ownership_change {
attach(p, value.id);
}
@@ -360,7 +438,7 @@ fn apply_command(
let mut v = value.clone();
v.id = *old;
p.relations.insert(*old, v);
reidentify(p, *old, value.id)?;
reidentify_tracked(p, *old, value.id, provenance)?;
if pending_deletions.remove(old) {
pending_deletions.insert(value.id);
}
@@ -378,7 +456,9 @@ fn apply_command(
let mut v = value.clone();
v.id = *old;
p.diagrams.insert(*old, v);
reidentify(p, *old, value.id)?;
// Replacing a diagram can remove embedded placement identities.
provenance.retain_present(p);
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::CreateArtifact(value) => {
@@ -393,7 +473,7 @@ fn apply_command(
let mut v = value.clone();
v.id = *old;
p.artifact_links.insert(*old, v);
reidentify(p, *old, value.id)?;
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::CreateTrace(value) => {
@@ -408,7 +488,7 @@ fn apply_command(
let mut v = value.clone();
v.id = *old;
p.traces.insert(*old, v);
reidentify(p, *old, value.id)?;
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::CreateNode { diagram, node } => {
@@ -432,7 +512,7 @@ fn apply_command(
let mut n = node.clone();
n.id = *original;
d.nodes.insert(*original, n);
reidentify(p, *original, node.id)?;
reidentify_tracked(p, *original, node.id, provenance)?;
Some(node.id)
}
Command::RelocateNode { node, diagram } => {
@@ -467,7 +547,7 @@ fn apply_command(
name,
description,
} => {
reidentify(p, p.id, *id)?;
reidentify_tracked(p, p.id, *id, provenance)?;
p.alias = alias.clone();
p.name = name.clone();
p.description = description.clone();
@@ -483,7 +563,7 @@ fn apply_command(
Some(*element)
}
Command::Reidentify(old, new) => {
reidentify(p, *old, *new)?;
reidentify_tracked(p, *old, *new, provenance)?;
if pending_deletions.remove(old) {
pending_deletions.insert(*new);
}
@@ -518,7 +598,13 @@ fn apply_command(
items: vec![],
},
)?;
return apply_command(p, &Command::Batch(commands), depth + 1, pending_deletions);
return apply_command(
p,
&Command::Batch(commands),
depth + 1,
pending_deletions,
provenance,
);
}
Some(p.relate(*kind, name, *source, *target))
}
@@ -530,6 +616,7 @@ fn apply_command(
return Err(missing());
}
crate::references::delete_cascade_deferred(p, *id, pending_deletions);
provenance.retain_present(p);
None
}
Command::AddStep(owner, exchange) => {
@@ -569,6 +656,7 @@ fn apply_command(
.and_then(|s| s.get(*index))
.ok_or_else(|| ModelError("Step index is out of range.".into()))?;
crate::references::delete_cascade_deferred(p, id, pending_deletions);
provenance.retain_present(p);
Some(*owner)
}
Command::MoveStep(owner, from, to) => {