Add audit documentation and enhance two-way sync handling

- Introduced a comprehensive audit guide for the production-grade sync feature, detailing the evaluation criteria for round-trip identity and code quality.
- Added a new plan for auditing the two-way sync Ref property feature, outlining specific tasks and expected behaviors.
- Updated `ServeSession.lua` to include the instance map in patch updates, ensuring accurate property encoding.
- Enhanced `encodeInstance.lua` to prevent encoding Ref properties during instance addition, delegating this to the patch update process.
- Implemented regression tests to highlight known limitations in Ref handling, ensuring better coverage and identification of existing bugs.

These changes improve the clarity of the sync feature's audit process and strengthen the handling of Ref properties in the system.
This commit is contained in:
Joe 2026-02-14 12:08:12 -06:00
parent c181172460
commit 4070b48308
15 changed files with 1559 additions and 138 deletions

View file

@ -390,6 +390,115 @@ impl JobThreadContext {
}
}
/// After an instance is renamed, scan all meta/model files on disk for
/// `Rojo_Ref_*` attributes whose path references the old name, and update
/// them to the new path.
///
/// The Rojo_Ref_* attributes only exist on the filesystem (in meta/model
/// JSON5 files), not in the tree's Attributes property. The tree stores
/// Variant::Ref properties directly by instance ID.
fn update_ref_paths_after_rename(
&self,
tree: &mut crate::snapshot::RojoTree,
old_path: &str,
new_path: &str,
) {
if old_path == new_path {
return;
}
let root_id = tree.root().id();
let mut files_to_check: Vec<std::path::PathBuf> = Vec::new();
// The old and new tree path segments (last component of each path).
// Used to map stale InstigatingSource paths to their actual on-disk
// locations after a directory rename.
let old_segment = old_path.rsplit('/').next().unwrap_or(old_path);
let new_segment = new_path.rsplit('/').next().unwrap_or(new_path);
// Collect all filesystem paths from instances in the tree.
// We check each instance's instigating source for meta/model files.
for inst_with_meta in tree.descendants(root_id) {
if let Some(source) = &inst_with_meta.metadata().instigating_source {
let inst_path = source.path();
// If the InstigatingSource path contains the old segment (stale
// after directory rename), map it to the new segment to find the
// actual file on disk.
let inst_path = {
let p_str = inst_path.to_string_lossy();
if !inst_path.exists() && p_str.contains(old_segment) {
PathBuf::from(p_str.replace(old_segment, new_segment).as_str())
} else {
inst_path.to_path_buf()
}
};
// For directory-format instances, check init.meta.json5
if inst_path.is_dir() {
let init_meta = inst_path.join("init.meta.json5");
if init_meta.exists() {
files_to_check.push(init_meta);
}
} else {
let file_name = inst_path.file_name().and_then(|f| f.to_str()).unwrap_or("");
// Model files contain attributes inline
if file_name.ends_with(".model.json5") || file_name.ends_with(".model.json") {
files_to_check.push(inst_path.to_path_buf());
}
// Check for adjacent .meta.json5
if let Some(parent) = inst_path.parent() {
let stem = inst_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let base = stem
.strip_suffix(".server")
.or_else(|| stem.strip_suffix(".client"))
.or_else(|| stem.strip_suffix(".plugin"))
.or_else(|| stem.strip_suffix(".local"))
.or_else(|| stem.strip_suffix(".legacy"))
.unwrap_or(stem);
let meta_path = parent.join(format!("{}.meta.json5", base));
if meta_path.exists() {
files_to_check.push(meta_path);
}
}
}
}
}
let mut updated_count = 0;
for file_path in &files_to_check {
self.suppress_path(file_path);
match crate::syncback::meta::update_ref_paths_in_file(file_path, old_path, new_path) {
Ok(true) => {
updated_count += 1;
}
Ok(false) => {
// No stale refs in this file; undo suppression
self.unsuppress_path(file_path);
}
Err(err) => {
self.unsuppress_path(file_path);
log::warn!(
"Failed to update Rojo_Ref_* paths in {}: {}",
self.display_path(file_path),
err
);
}
}
}
if updated_count > 0 {
log::info!(
"Updated Rojo_Ref_* paths in {} file(s): '{}' -> '{}'",
updated_count,
old_path,
new_path
);
}
}
/// Remove the `name` field from a `.meta.json5` file, suppressing filesystem
/// events. If the file becomes empty after removal, deletes it entirely.
fn remove_meta_name_field(&self, meta_path: &Path) {
@ -861,6 +970,14 @@ impl JobThreadContext {
for update in &patch_set.updated_instances {
let id = update.id;
// Capture the old path BEFORE rename for Rojo_Ref_* path updates.
// Must be computed before the `tree.get_instance(id)` borrow.
let old_ref_path = if update.changed_name.is_some() {
Some(tree.inner().full_path_of(id, "/"))
} else {
None
};
if let Some(instance) = tree.get_instance(id) {
// Track the current source file path — rename and ClassName
// handlers may move the file, so the Source write must target
@ -1363,6 +1480,32 @@ impl JobThreadContext {
} else {
log::warn!("Cannot update instance {:?}, it does not exist.", id);
}
// After rename, update any Rojo_Ref_* paths that referenced the
// old path of this instance (or its descendants). Done outside the
// `if let Some(instance)` block to avoid borrow conflicts.
//
// NOTE: The tree name hasn't been updated yet (apply_patch_set
// runs after this loop), so we can't use full_path_of for the
// new path. Construct it by replacing the last path segment.
if let Some(ref old_ref_path) = old_ref_path {
if let Some(ref new_name) = update.changed_name {
let new_ref_path =
if let Some((parent, _old_name)) = old_ref_path.rsplit_once('/') {
format!("{}/{}", parent, crate::escape_ref_path_segment(new_name))
} else {
// Root-level instance (no parent in path)
crate::escape_ref_path_segment(new_name).into_owned()
};
if *old_ref_path != new_ref_path {
self.update_ref_paths_after_rename(
&mut tree,
old_ref_path,
&new_ref_path,
);
}
}
}
}
let applied = apply_patch_set(&mut tree, patch_set);

View file

@ -1,6 +1,9 @@
use std::{fmt, sync::Arc};
use std::{borrow::Cow, fmt, sync::Arc};
use rbx_dom_weak::{types::Ref, WeakDom};
use rbx_dom_weak::{
types::{Ref, Variant},
WeakDom,
};
use serde::{Deserialize, Serialize};
/// Legacy: ID attribute on target instances (kept for backwards compatibility)
@ -19,12 +22,124 @@ pub fn ref_attribute_name(prop_name: &str) -> String {
format!("{REF_PATH_ATTRIBUTE_PREFIX}{prop_name}")
}
/// Compute the `Rojo_Target_*` attribute name for a given property name.
///
/// Example: `ref_target_attribute_name("Value")` returns `"Rojo_Target_Value"`.
pub fn ref_target_attribute_name(prop_name: &str) -> String {
format!("{REF_POINTER_ATTRIBUTE_PREFIX}{prop_name}")
}
/// Escape "/" in an instance name for use in ref paths.
/// Uses "\/" as escape sequence. Also escapes existing backslashes to "\\"
/// so the escaping is unambiguous.
pub fn escape_ref_path_segment(name: &str) -> Cow<'_, str> {
if name.contains('/') || name.contains('\\') {
Cow::Owned(name.replace('\\', "\\\\").replace('/', "\\/"))
} else {
Cow::Borrowed(name)
}
}
/// Unescape a ref path segment back to an instance name.
/// Reverses the escaping done by `escape_ref_path_segment`.
pub fn unescape_ref_path_segment(segment: &str) -> Cow<'_, str> {
if segment.contains('\\') {
let mut result = String::with_capacity(segment.len());
let mut chars = segment.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('/') => result.push('/'),
Some('\\') => result.push('\\'),
Some(other) => {
result.push('\\');
result.push(other);
}
None => result.push('\\'),
}
} else {
result.push(c);
}
}
Cow::Owned(result)
} else {
Cow::Borrowed(segment)
}
}
/// Split a ref path string into segments, handling escaped "/" characters.
/// Splits on unescaped "/" only, then unescapes each segment.
pub fn split_ref_path(path: &str) -> Vec<String> {
if path.is_empty() {
return Vec::new();
}
let mut segments = Vec::new();
let mut current = String::new();
let mut chars = path.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
// Escaped character -- keep both the backslash and next char
current.push(c);
if let Some(&next) = chars.peek() {
current.push(next);
chars.next();
}
} else if c == '/' {
// Unescaped separator
segments.push(unescape_ref_path_segment(&current).into_owned());
current = String::new();
} else {
current.push(c);
}
}
// Push the last segment
segments.push(unescape_ref_path_segment(&current).into_owned());
segments
}
/// Compute the path to a target instance for use in a `Rojo_Ref_*` attribute.
/// Returns the slash-separated path from the root (root name excluded).
/// Instance names containing "/" are escaped as "\/" to prevent ambiguity.
///
/// Example: an instance at `DataModel > Workspace > Part1` returns `"Workspace/Part1"`.
/// An instance named `"A/B"` at Workspace returns `"Workspace/A\/B"`.
pub fn ref_target_path(dom: &WeakDom, target_ref: Ref) -> String {
dom.full_path_of(target_ref, "/")
let root_ref = dom.root_ref();
let mut components: Vec<Cow<'_, str>> = dom
.ancestors_of(target_ref)
.filter(|inst| inst.referent() != root_ref)
.map(|inst| escape_ref_path_segment(&inst.name))
.collect();
components.reverse();
components.join("/")
}
/// Extract a string value from a Variant that may be String or BinaryString.
/// Returns None with a warning for non-string types or invalid UTF-8.
/// Used by compute_ref_properties and defer_ref_properties for parsing
/// Rojo_Ref_* and Rojo_Target_* attribute values.
pub fn variant_as_str<'a>(value: &'a Variant, attr_name: &str) -> Option<&'a str> {
match value {
Variant::String(s) => Some(s.as_str()),
Variant::BinaryString(bytes) => match std::str::from_utf8(bytes.as_ref()) {
Ok(s) => Some(s),
Err(_) => {
log::warn!("Attribute {attr_name} contains invalid UTF-8 BinaryString");
None
}
},
_ => {
log::warn!(
"Attribute {attr_name} is of type {:?} when it was expected to be a String",
value.ty()
);
None
}
}
}
// TODO add an internment strategy for RojoRefs
@ -110,4 +225,117 @@ mod tests {
let dom = WeakDom::new(InstanceBuilder::new("DataModel"));
assert_eq!(ref_target_path(&dom, dom.root_ref()), "");
}
// -----------------------------------------------------------------------
// Escape/unescape tests
// -----------------------------------------------------------------------
#[test]
fn escape_name_without_slash() {
assert_eq!(escape_ref_path_segment("Part1"), Cow::Borrowed("Part1"));
}
#[test]
fn escape_name_with_slash() {
let escaped = escape_ref_path_segment("A/B");
assert_eq!(escaped.as_ref(), "A\\/B");
}
#[test]
fn escape_name_with_backslash() {
let escaped = escape_ref_path_segment("A\\B");
assert_eq!(escaped.as_ref(), "A\\\\B");
}
#[test]
fn escape_name_with_both() {
let escaped = escape_ref_path_segment("A/B\\C");
assert_eq!(escaped.as_ref(), "A\\/B\\\\C");
}
#[test]
fn unescape_plain_segment() {
assert_eq!(unescape_ref_path_segment("Part1"), Cow::Borrowed("Part1"));
}
#[test]
fn unescape_escaped_slash() {
assert_eq!(unescape_ref_path_segment("A\\/B").as_ref(), "A/B");
}
#[test]
fn unescape_escaped_backslash() {
assert_eq!(unescape_ref_path_segment("A\\\\B").as_ref(), "A\\B");
}
#[test]
fn escape_unescape_round_trip() {
let original = "A/B\\C/D";
let escaped = escape_ref_path_segment(original);
let unescaped = unescape_ref_path_segment(&escaped);
assert_eq!(unescaped.as_ref(), original);
}
// -----------------------------------------------------------------------
// split_ref_path tests
// -----------------------------------------------------------------------
#[test]
fn split_simple_path() {
let segments = split_ref_path("Workspace/Model/Part");
assert_eq!(segments.len(), 3);
assert_eq!(segments[0], "Workspace");
assert_eq!(segments[1], "Model");
assert_eq!(segments[2], "Part");
}
#[test]
fn split_path_with_escaped_slash() {
let segments = split_ref_path("Workspace/A\\/B/Part");
assert_eq!(segments.len(), 3);
assert_eq!(segments[0], "Workspace");
assert_eq!(segments[1], "A/B"); // unescaped
assert_eq!(segments[2], "Part");
}
#[test]
fn split_empty_path() {
let segments = split_ref_path("");
assert!(segments.is_empty());
}
#[test]
fn split_single_segment() {
let segments = split_ref_path("Workspace");
assert_eq!(segments.len(), 1);
assert_eq!(segments[0], "Workspace");
}
// -----------------------------------------------------------------------
// ref_target_path with "/" in instance name
// -----------------------------------------------------------------------
#[test]
fn ref_target_path_name_with_slash() {
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let ws = dom.insert(dom.root_ref(), InstanceBuilder::new("Workspace"));
let child = dom.insert(ws, InstanceBuilder::new("A/B"));
let path = ref_target_path(&dom, child);
assert_eq!(path, "Workspace/A\\/B");
// Verify round-trip: split_ref_path should produce the correct segments
let segments = split_ref_path(&path);
assert_eq!(segments.len(), 2);
assert_eq!(segments[0], "Workspace");
assert_eq!(segments[1], "A/B");
}
// -----------------------------------------------------------------------
// ref_target_attribute_name tests
// -----------------------------------------------------------------------
#[test]
fn ref_target_attr_name_value() {
assert_eq!(ref_target_attribute_name("Value"), "Rojo_Target_Value");
}
}

View file

@ -330,67 +330,25 @@ fn defer_ref_properties(tree: &mut RojoTree, id: Ref, context: &mut PatchApplyCo
let mut attr_id = None;
for (attr_name, attr_value) in attributes.iter() {
if attr_name == REF_ID_ATTRIBUTE_NAME {
if let Variant::String(specified_id) = attr_value {
attr_id = Some(RojoRef::new(specified_id.clone()));
} else if let Variant::BinaryString(specified_id) = attr_value {
if let Ok(str) = std::str::from_utf8(specified_id.as_ref()) {
attr_id = Some(RojoRef::new(str.to_string()))
} else {
log::error!("Specified IDs must be valid UTF-8 strings.")
}
} else {
log::warn!(
"Attribute {attr_name} is of type {:?} when it was \
expected to be a String",
attr_value.ty()
)
if let Some(id_str) = crate::variant_as_str(attr_value, attr_name) {
attr_id = Some(RojoRef::new(id_str.to_string()));
}
}
if let Some(prop_name) = attr_name.strip_prefix(REF_POINTER_ATTRIBUTE_PREFIX) {
context.instances_needing_attr_cleanup.insert(id);
if let Variant::String(prop_value) = attr_value {
if let Some(id_str) = crate::variant_as_str(attr_value, attr_name) {
context
.attribute_refs_to_rewrite
.insert(id, (ustr(prop_name), prop_value.clone()));
} else if let Variant::BinaryString(prop_value) = attr_value {
if let Ok(str) = std::str::from_utf8(prop_value.as_ref()) {
context
.attribute_refs_to_rewrite
.insert(id, (ustr(prop_name), str.to_string()));
} else {
log::error!("IDs specified by referent property attributes must be valid UTF-8 strings.")
}
} else {
log::warn!(
"Attribute {attr_name} is of type {:?} when it was \
expected to be a String",
attr_value.ty()
)
.insert(id, (ustr(prop_name), id_str.to_string()));
}
}
// Handle path-based references (Rojo_Ref_)
if let Some(prop_name) = attr_name.strip_prefix(REF_PATH_ATTRIBUTE_PREFIX) {
context.instances_needing_attr_cleanup.insert(id);
if let Variant::String(path) = attr_value {
if let Some(path) = crate::variant_as_str(attr_value, attr_name) {
context
.path_refs_to_rewrite
.insert(id, (ustr(prop_name), path.clone()));
} else if let Variant::BinaryString(path) = attr_value {
if let Ok(str) = std::str::from_utf8(path.as_ref()) {
context
.path_refs_to_rewrite
.insert(id, (ustr(prop_name), str.to_string()));
} else {
log::error!(
"Paths specified by referent property attributes must be valid UTF-8 strings."
)
}
} else {
log::warn!(
"Attribute {attr_name} is of type {:?} when it was \
expected to be a String",
attr_value.ty()
)
.insert(id, (ustr(prop_name), path.to_string()));
}
}
}

View file

@ -288,27 +288,28 @@ fn compute_ref_properties(
};
for (attr_name, attr_value) in attributes.iter() {
// Handle new path-based refs (Rojo_Ref_)
// Handle legacy ID-based refs (Rojo_Target_) FIRST -- lower priority.
// If a Rojo_Ref_* attribute also exists for the same property, it will
// overwrite this entry below (path-based system has priority).
if let Some(prop_name) = attr_name.strip_prefix(REF_POINTER_ATTRIBUTE_PREFIX) {
let Some(id_str) = crate::variant_as_str(attr_value, attr_name) else {
continue;
};
let rojo_ref = RojoRef::new(id_str.to_string());
if let Some(target_id) = tree.get_specified_id(&rojo_ref) {
map.insert(ustr(prop_name), Some(Variant::Ref(target_id)));
} else {
map.insert(ustr(prop_name), None);
}
continue;
}
// Handle path-based refs (Rojo_Ref_) SECOND -- higher priority.
// Overwrites any Rojo_Target_* entry for the same property since
// path-based is the preferred system.
if let Some(prop_name) = attr_name.strip_prefix(REF_PATH_ATTRIBUTE_PREFIX) {
let path = match attr_value {
Variant::String(str) => str.as_str(),
Variant::BinaryString(bytes) => {
if let Ok(str) = std::str::from_utf8(bytes.as_ref()) {
str
} else {
log::warn!(
"Paths specified by referent property attributes must be valid UTF-8 strings"
);
continue;
}
}
_ => {
log::warn!(
"Attribute {attr_name} is of type {:?} when it was expected to be a String",
attr_value.ty()
);
continue;
}
let Some(path) = crate::variant_as_str(attr_value, attr_name) else {
continue;
};
if let Some(target_id) = tree.get_instance_by_path(path) {
map.insert(ustr(prop_name), Some(Variant::Ref(target_id)));
@ -318,38 +319,6 @@ fn compute_ref_properties(
}
continue;
}
// Handle legacy ID-based refs (Rojo_Target_)
let prop_name = match attr_name.strip_prefix(REF_POINTER_ATTRIBUTE_PREFIX) {
Some(str) => str,
None => continue,
};
let rojo_ref = match attr_value {
Variant::String(str) => RojoRef::new(str.clone()),
Variant::BinaryString(bytes) => {
if let Ok(str) = std::str::from_utf8(bytes.as_ref()) {
RojoRef::new(str.to_string())
} else {
log::warn!(
"IDs specified by referent property attributes must be valid UTF-8 strings"
);
continue;
}
}
_ => {
log::warn!(
"Attribute {attr_name} is of type {:?} when it was \
expected to be a String",
attr_value.ty()
);
continue;
}
};
if let Some(target_id) = tree.get_specified_id(&rojo_ref) {
map.insert(ustr(prop_name), Some(Variant::Ref(target_id)));
} else {
map.insert(ustr(prop_name), None);
}
}
map

View file

@ -239,7 +239,9 @@ impl RojoTree {
return Some(self.get_root_id());
}
let segments: Vec<&str> = path.split('/').collect();
// Use split_ref_path to handle escaped "/" in instance names.
// An instance named "A/B" is stored as "A\/B" in the path.
let segments = crate::split_ref_path(path);
let mut current_ref = self.get_root_id();
// The root of the tree doesn't appear in the path, so we start with
@ -252,7 +254,7 @@ impl RojoTree {
let mut found = false;
for &child_ref in children {
let child = self.inner.get_by_ref(child_ref)?;
if child.name == *segment {
if child.name == segment.as_str() {
current_ref = child_ref;
found = true;
break;

View file

@ -90,6 +90,54 @@ pub fn remove_model_name(model_path: &Path) -> anyhow::Result<RemoveNameOutcome>
Ok(RemoveNameOutcome::FieldRemoved)
}
/// Update `Rojo_Ref_*` attribute paths in a meta/model JSON5 file.
///
/// For each attribute whose key starts with `Rojo_Ref_` and whose string
/// value starts with `old_prefix`, replaces the prefix with `new_prefix`.
/// Returns true if any attribute was updated.
pub fn update_ref_paths_in_file(
file_path: &Path,
old_prefix: &str,
new_prefix: &str,
) -> anyhow::Result<bool> {
use crate::REF_PATH_ATTRIBUTE_PREFIX;
if !file_path.exists() {
return Ok(false);
}
let bytes = match fs::read(file_path) {
Ok(b) => b,
Err(_) => return Ok(false),
};
let mut val = match crate::json::from_slice::<serde_json::Value>(&bytes) {
Ok(v) if v.is_object() => v,
_ => return Ok(false),
};
let mut updated = false;
if let Some(attrs) = val.get_mut("attributes").and_then(|a| a.as_object_mut()) {
for (key, value) in attrs.iter_mut() {
if key.starts_with(REF_PATH_ATTRIBUTE_PREFIX) {
if let Some(path_str) = value.as_str() {
if path_str == old_prefix || path_str.starts_with(&format!("{old_prefix}/")) {
let new_path = format!("{new_prefix}{}", &path_str[old_prefix.len()..]);
*value = serde_json::Value::String(new_path);
updated = true;
}
}
}
}
}
if updated {
let content = crate::json::to_vec_pretty_sorted(&val)?;
fs::write(file_path, &content)?;
}
Ok(updated)
}
/// Remove the `name` field from a `.meta.json5` file.
///
/// If the file becomes an empty object after removal, deletes it entirely.

View file

@ -10,8 +10,8 @@ use rbx_dom_weak::{
};
use crate::{
ref_attribute_name, syncback::snapshot::inst_path, REF_ID_ATTRIBUTE_NAME,
REF_PATH_ATTRIBUTE_PREFIX, REF_POINTER_ATTRIBUTE_PREFIX,
ref_attribute_name, ref_target_attribute_name, syncback::snapshot::inst_path,
REF_ID_ATTRIBUTE_NAME, REF_PATH_ATTRIBUTE_PREFIX, REF_POINTER_ATTRIBUTE_PREFIX,
};
pub struct RefLinks {
@ -248,7 +248,7 @@ pub fn link_referents(links: RefLinks, dom: &mut WeakDom) -> anyhow::Result<()>
let id =
get_existing_id(target).expect("all ID-based targets should have an ID by now");
id_attrs.push((
format!("{REF_POINTER_ATTRIBUTE_PREFIX}{}", link.name),
ref_target_attribute_name(&link.name),
Variant::String(id.to_owned()),
));
}

View file

@ -373,7 +373,8 @@ pub fn filter_out_property(inst: &Instance, prop_name: &str) -> bool {
}
pub fn inst_path(dom: &WeakDom, referent: Ref) -> String {
dom.full_path_of(referent, "/")
// Use ref_target_path which handles escaping "/" in instance names
crate::ref_target_path(dom, referent)
}
#[cfg(test)]

View file

@ -721,6 +721,41 @@ impl ApiService {
/// Returns true if the path is unique, false if duplicates exist at any level.
/// This is O(d) where d is the depth of the instance.
/// Records any duplicate path issues via the stats tracker.
/// Check if a Ref target's path is unique (no duplicate-named siblings
/// at any ancestor level). Used for warning about ambiguous Rojo_Ref_* paths.
fn is_ref_path_unique(tree: &crate::snapshot::RojoTree, target_ref: Ref) -> bool {
let mut current_ref = target_ref;
loop {
let current = match tree.get_instance(current_ref) {
Some(inst) => inst,
None => return false,
};
let parent_ref = current.parent();
if parent_ref.is_none() {
// Reached root -- path is unique at all levels
return true;
}
// Check if any sibling has the same name
if let Some(parent) = tree.get_instance(parent_ref) {
let my_name = current.name();
let has_dup = parent.children().iter().any(|&child_ref| {
child_ref != current_ref
&& tree
.get_instance(child_ref)
.is_some_and(|c| c.name() == my_name)
});
if has_dup {
return false;
}
}
current_ref = parent_ref;
}
}
fn is_tree_path_unique_with_cache(
tree: &crate::snapshot::RojoTree,
target_ref: Ref,
@ -2303,10 +2338,11 @@ impl ApiService {
continue;
}
// Skip UniqueId - internal Roblox identifiers, not meaningful for syncback.
// Note: Ref properties are handled upstream in syncback_updated_properties()
// Skip UniqueId and Ref - they don't serialize to JSON.
// Ref properties are handled upstream in syncback_updated_properties()
// where they are converted to Rojo_Ref_* path-based attributes.
if matches!(value, Variant::UniqueId(_)) {
// UniqueId is internal to Roblox and not meaningful for syncback.
if matches!(value, Variant::UniqueId(_) | Variant::Ref(_)) {
continue;
}
@ -2577,6 +2613,21 @@ impl ApiService {
} else if tree.get_instance(*target_ref).is_some() {
// Valid target: compute path and add as Rojo_Ref_* attribute
let path = crate::ref_target_path(tree.inner(), *target_ref);
// Warn if the path is ambiguous (duplicate-named siblings
// at any ancestor level). The ref will still be written,
// but may resolve to the wrong target on rebuild.
if !Self::is_ref_path_unique(tree, *target_ref) {
log::warn!(
"Ref property '{}' for instance {:?} has an ambiguous \
path '{}' (duplicate-named siblings exist). The ref may \
resolve to the wrong target on rebuild.",
key,
update.id,
path
);
}
ref_attributes.insert(attr_name, serde_json::Value::String(path));
} else {
log::warn!(