initial work

This commit is contained in:
Joe 2026-02-14 10:56:25 -06:00
parent 252325a7de
commit bf65d8bfa8
7 changed files with 143 additions and 20 deletions

View file

@ -33,13 +33,13 @@ return function(instanceMap, propertyChanges, syncSourceOnly)
if syncSourceOnly then if syncSourceOnly then
-- Filter to only Source property when server is in source-only mode -- Filter to only Source property when server is in source-only mode
if properties.Source then if properties.Source then
local update = encodePatchUpdate(instance, instanceId, { Source = true }) local update = encodePatchUpdate(instance, instanceId, { Source = true }, instanceMap)
if update then if update then
table.insert(patch.updated, update) table.insert(patch.updated, update)
end end
end end
else else
local update = encodePatchUpdate(instance, instanceId, properties) local update = encodePatchUpdate(instance, instanceId, properties, instanceMap)
if update then if update then
table.insert(patch.updated, update) table.insert(patch.updated, update)
end end

View file

@ -5,7 +5,9 @@ local RbxDom = require(Packages.RbxDom)
local encodeProperty = require(script.Parent.encodeProperty) local encodeProperty = require(script.Parent.encodeProperty)
local UNENCODABLE_DATA_TYPES = require(script.Parent.propertyFilter) local UNENCODABLE_DATA_TYPES = require(script.Parent.propertyFilter)
return function(instance, instanceId, properties) local NULL_REF = "00000000000000000000000000000000"
return function(instance, instanceId, properties, instanceMap)
local update = { local update = {
id = instanceId, id = instanceId,
changedProperties = {}, changedProperties = {},
@ -26,6 +28,36 @@ return function(instance, instanceId, properties)
continue continue
end end
-- Ref properties are encoded using the InstanceMap to resolve
-- Studio Instance references to server-side Ref IDs.
if descriptor.dataType == "Ref" then
local readSuccess, readResult = descriptor:read(instance)
if not readSuccess then
Log.debug("Could not read Ref property {:?}.{}: {}", instance, propertyName, readResult)
continue
end
if readResult == nil then
-- Nil ref: encode as null ref
update.changedProperties[propertyName] = { Ref = NULL_REF }
elseif instanceMap then
local targetId = instanceMap.fromInstances[readResult]
if targetId then
update.changedProperties[propertyName] = { Ref = targetId }
else
Log.warn(
"Cannot sync Ref property {:?}.{}: target {:?} is not tracked by Atlas",
instance,
propertyName,
readResult
)
end
else
Log.warn("Cannot encode Ref property {:?}.{}: no InstanceMap provided", instance, propertyName)
end
continue
end
local encodeSuccess, encodeResult = encodeProperty(instance, propertyName, descriptor) local encodeSuccess, encodeResult = encodeProperty(instance, propertyName, descriptor)
if not encodeSuccess then if not encodeSuccess then

View file

@ -4,12 +4,14 @@
Types listed here are skipped during property encoding because they Types listed here are skipped during property encoding because they
cannot be serialized to JSON or are meaningless for syncback. cannot be serialized to JSON or are meaningless for syncback.
Note: Ref properties are NOT blacklisted here. They are handled
specially in encodePatchUpdate.lua using the InstanceMap to resolve
Studio Instance references to server-side Ref IDs.
]] ]]
-- Property data types that cannot be encoded standalone. -- Property data types that cannot be encoded standalone.
-- Matches the Variant::Ref / Variant::UniqueId filter in api.rs.
local UNENCODABLE_DATA_TYPES = { local UNENCODABLE_DATA_TYPES = {
Ref = true, -- Instance references; need the InstanceMap, not encodable standalone
UniqueId = true, -- Internal Roblox identifiers; not meaningful for syncback UniqueId = true, -- Internal Roblox identifiers; not meaningful for syncback
} }

View file

@ -779,6 +779,11 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
local guid = HttpService:GenerateGUID(false) local guid = HttpService:GenerateGUID(false)
local tempRef = string.gsub(guid, "-", ""):lower() local tempRef = string.gsub(guid, "-", ""):lower()
pullPatch.added[tempRef] = encoded pullPatch.added[tempRef] = encoded
-- Note: We intentionally do NOT pre-insert into InstanceMap here.
-- The VFS watcher will process the written files and assign a
-- server-side Ref ID. Any Ref properties targeting this instance
-- will be deferred by the ChangeBatcher and resolved once the
-- server ID appears in the InstanceMap via forward-sync.
-- Log at info level since this is a major file creation operation -- Log at info level since this is a major file creation operation
Log.info("[Pull] Create file: {} ({})", instancePath, instanceClass) Log.info("[Pull] Create file: {} ({})", instancePath, instanceClass)
end end

View file

@ -1,5 +1,6 @@
use std::{fmt, sync::Arc}; use std::{fmt, sync::Arc};
use rbx_dom_weak::{types::Ref, WeakDom};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Legacy: ID attribute on target instances (kept for backwards compatibility) /// Legacy: ID attribute on target instances (kept for backwards compatibility)
@ -11,6 +12,21 @@ pub const REF_POINTER_ATTRIBUTE_PREFIX: &str = "Rojo_Target_";
/// No modification to the target instance is needed. /// No modification to the target instance is needed.
pub const REF_PATH_ATTRIBUTE_PREFIX: &str = "Rojo_Ref_"; pub const REF_PATH_ATTRIBUTE_PREFIX: &str = "Rojo_Ref_";
/// Compute the `Rojo_Ref_*` attribute name for a given property name.
///
/// Example: `ref_attribute_name("PrimaryPart")` returns `"Rojo_Ref_PrimaryPart"`.
pub fn ref_attribute_name(prop_name: &str) -> String {
format!("{REF_PATH_ATTRIBUTE_PREFIX}{prop_name}")
}
/// 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).
///
/// Example: an instance at `DataModel > Workspace > Part1` returns `"Workspace/Part1"`.
pub fn ref_target_path(dom: &WeakDom, target_ref: Ref) -> String {
dom.full_path_of(target_ref, "/")
}
// TODO add an internment strategy for RojoRefs // TODO add an internment strategy for RojoRefs
// Something like what rbx-dom does for SharedStrings probably works // Something like what rbx-dom does for SharedStrings probably works

View file

@ -10,8 +10,8 @@ use rbx_dom_weak::{
}; };
use crate::{ use crate::{
syncback::snapshot::inst_path, REF_ID_ATTRIBUTE_NAME, REF_PATH_ATTRIBUTE_PREFIX, ref_attribute_name, syncback::snapshot::inst_path, REF_ID_ATTRIBUTE_NAME,
REF_POINTER_ATTRIBUTE_PREFIX, REF_PATH_ATTRIBUTE_PREFIX, REF_POINTER_ATTRIBUTE_PREFIX,
}; };
pub struct RefLinks { pub struct RefLinks {
@ -232,7 +232,7 @@ pub fn link_referents(links: RefLinks, dom: &mut WeakDom) -> anyhow::Result<()>
if let Some(path_refs) = links.path_links.get(&inst_id) { if let Some(path_refs) = links.path_links.get(&inst_id) {
for link in path_refs { for link in path_refs {
path_attrs.push(( path_attrs.push((
format!("{REF_PATH_ATTRIBUTE_PREFIX}{}", link.name), ref_attribute_name(&link.name),
Variant::String(link.path.clone()), Variant::String(link.path.clone()),
)); ));
} }

View file

@ -2285,8 +2285,10 @@ impl ApiService {
continue; continue;
} }
// Skip Ref and UniqueId - they don't serialize to JSON (matches dedicated syncback) // Skip UniqueId - internal Roblox identifiers, not meaningful for syncback.
if matches!(value, Variant::Ref(_) | Variant::UniqueId(_)) { // Note: Ref properties are handled upstream in syncback_updated_properties()
// where they are converted to Rojo_Ref_* path-based attributes.
if matches!(value, Variant::UniqueId(_)) {
continue; continue;
} }
@ -2537,27 +2539,58 @@ impl ApiService {
"ModuleScript" | "Script" | "LocalScript" "ModuleScript" | "Script" | "LocalScript"
); );
// Collect the non-Source properties as a HashMap<String, Variant> // Extract Ref properties and convert to Rojo_Ref_* attributes.
// for reuse with filter_properties_for_meta // Non-Ref properties are collected separately for filter_properties_for_meta.
let mut ref_attributes: indexmap::IndexMap<String, serde_json::Value> =
indexmap::IndexMap::new();
let mut remove_attributes: Vec<String> = Vec::new();
let mut props: HashMap<String, Variant> = HashMap::new(); let mut props: HashMap<String, Variant> = HashMap::new();
for (key, value) in &update.changed_properties { for (key, value) in &update.changed_properties {
if key == "Source" { if key == "Source" {
continue; continue;
} }
if let Some(v) = value { match value {
Some(Variant::Ref(target_ref)) => {
let attr_name = crate::ref_attribute_name(key);
if target_ref.is_none() {
// Null ref: mark attribute for removal
remove_attributes.push(attr_name);
} 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);
ref_attributes.insert(attr_name, serde_json::Value::String(path));
} else {
log::warn!(
"Cannot persist Ref property '{}' for instance {:?}: \
target {:?} not found in tree",
key,
update.id,
target_ref
);
}
}
Some(v) => {
props.insert(key.to_string(), v.clone()); props.insert(key.to_string(), v.clone());
} }
None => {}
}
} }
if props.is_empty() { if props.is_empty() && ref_attributes.is_empty() && remove_attributes.is_empty() {
return Ok(()); return Ok(());
} }
let skip_prop = if is_script { Some("Source") } else { None }; let skip_prop = if is_script { Some("Source") } else { None };
let (properties, attributes) = let (properties, mut attributes) =
self.filter_properties_for_meta(class_name.as_str(), &props, skip_prop); self.filter_properties_for_meta(class_name.as_str(), &props, skip_prop);
if properties.is_empty() && attributes.is_empty() { // Merge Rojo_Ref_* attributes into the attributes map
for (k, v) in ref_attributes {
attributes.insert(k, v);
}
if properties.is_empty() && attributes.is_empty() && remove_attributes.is_empty() {
return Ok(()); return Ok(());
} }
@ -2567,7 +2600,13 @@ impl ApiService {
let meta_path = inst_path.join("init.meta.json5"); let meta_path = inst_path.join("init.meta.json5");
// Read existing meta if present, merge with new properties // Read existing meta if present, merge with new properties
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?; let meta = self.merge_or_build_meta(
&meta_path,
None,
properties,
attributes,
&remove_attributes,
)?;
let content = crate::json::to_vec_pretty_sorted(&meta) let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize init.meta.json5")?; .context("Failed to serialize init.meta.json5")?;
self.suppress_path(&meta_path); self.suppress_path(&meta_path);
@ -2594,7 +2633,13 @@ impl ApiService {
.unwrap_or(file_stem); .unwrap_or(file_stem);
let meta_path = parent_dir.join(format!("{}.meta.json5", base_name)); let meta_path = parent_dir.join(format!("{}.meta.json5", base_name));
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?; let meta = self.merge_or_build_meta(
&meta_path,
None,
properties,
attributes,
&remove_attributes,
)?;
let content = crate::json::to_vec_pretty_sorted(&meta) let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize meta.json5")?; .context("Failed to serialize meta.json5")?;
self.suppress_path(&meta_path); self.suppress_path(&meta_path);
@ -2620,6 +2665,7 @@ impl ApiService {
Some(class_name.as_str()), Some(class_name.as_str()),
properties, properties,
attributes, attributes,
&remove_attributes,
)?; )?;
let content = crate::json::to_vec_pretty_sorted(&meta) let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize model file")?; .context("Failed to serialize model file")?;
@ -2637,7 +2683,13 @@ impl ApiService {
let file_stem = inst_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let file_stem = inst_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let meta_path = parent_dir.join(format!("{}.meta.json5", file_stem)); let meta_path = parent_dir.join(format!("{}.meta.json5", file_stem));
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?; let meta = self.merge_or_build_meta(
&meta_path,
None,
properties,
attributes,
&remove_attributes,
)?;
let content = crate::json::to_vec_pretty_sorted(&meta) let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize meta.json5")?; .context("Failed to serialize meta.json5")?;
self.suppress_path(&meta_path); self.suppress_path(&meta_path);
@ -2656,12 +2708,17 @@ impl ApiService {
/// Reads an existing meta/model JSON5 file and merges new properties into it. /// Reads an existing meta/model JSON5 file and merges new properties into it.
/// If the file doesn't exist, builds a fresh meta object. /// If the file doesn't exist, builds a fresh meta object.
///
/// `remove_attributes` is a list of attribute keys to remove from the existing
/// file (used for nil Ref cleanup, e.g., removing a stale `Rojo_Ref_PrimaryPart`
/// when PrimaryPart is set to nil).
fn merge_or_build_meta( fn merge_or_build_meta(
&self, &self,
existing_path: &std::path::Path, existing_path: &std::path::Path,
class_name: Option<&str>, class_name: Option<&str>,
new_properties: indexmap::IndexMap<String, serde_json::Value>, new_properties: indexmap::IndexMap<String, serde_json::Value>,
new_attributes: indexmap::IndexMap<String, serde_json::Value>, new_attributes: indexmap::IndexMap<String, serde_json::Value>,
remove_attributes: &[String],
) -> anyhow::Result<serde_json::Value> { ) -> anyhow::Result<serde_json::Value> {
use anyhow::Context; use anyhow::Context;
@ -2691,6 +2748,17 @@ impl ApiService {
} }
} }
// Remove attributes marked for deletion (e.g., stale Rojo_Ref_* entries)
if !remove_attributes.is_empty() {
if let Some(attrs) = obj.get_mut("attributes") {
if let Some(attrs_obj) = attrs.as_object_mut() {
for key in remove_attributes {
attrs_obj.remove(key);
}
}
}
}
// Merge attributes // Merge attributes
if !new_attributes.is_empty() { if !new_attributes.is_empty() {
let attrs = obj let attrs = obj