Refactor syncback process and improve code readability

- Reformatted the `SYNCBACK_SERVICES` list for better readability by aligning entries vertically.
- Simplified the `performSyncback` function by removing unnecessary Promise wrapping, enhancing clarity and maintainability.
- Updated the `NotConnectedPage` component to improve the structure of button elements and their associated tooltips.
- Refactored the `hydrate` and `matching` modules to streamline function calls and improve code organization.

These changes enhance the overall readability and maintainability of the syncback process and related components.
This commit is contained in:
Joe 2026-02-18 05:58:17 -06:00
parent 372a422b3d
commit 93ea0bdef6
16 changed files with 546 additions and 375 deletions

View file

@ -5,10 +5,20 @@ local ServerStorage = game:GetService("ServerStorage")
local RunService = game:GetService("RunService")
local SYNCBACK_SERVICES = {
"Lighting", "MaterialService", "ReplicatedFirst", "ReplicatedStorage",
"ServerScriptService", "ServerStorage", "SoundService", "StarterGui",
"StarterPack", "StarterPlayer", "Teams", "TextChatService",
"VoiceChatService", "Workspace",
"Lighting",
"MaterialService",
"ReplicatedFirst",
"ReplicatedStorage",
"ServerScriptService",
"ServerStorage",
"SoundService",
"StarterGui",
"StarterPack",
"StarterPlayer",
"Teams",
"TextChatService",
"VoiceChatService",
"Workspace",
}
local Rojo = script:FindFirstAncestor("Rojo")
@ -628,7 +638,6 @@ function App:performSyncback()
port = Config.defaultPort
end
Promise.new(function(resolve, reject)
local services = {}
for _, className in SYNCBACK_SERVICES do
local ok, service = pcall(game.FindService, game, className)
@ -650,17 +659,6 @@ function App:performSyncback()
})
Http.post(url, body)
:andThen(function(response)
if response.StatusCode >= 200 and response.StatusCode < 300 then
resolve()
else
reject(
`Syncback request failed ({response.StatusCode}): {response.Body or "unknown error"}`
)
end
end)
:catch(reject)
end)
:andThen(function()
Log.info("Syncback data sent to server.")
self:addNotification({

View file

@ -40,10 +40,8 @@ local function hydrate(instanceMap, virtualInstances, rootId, rootInstance, sess
end
end
local result = Matching.matchChildren(
session, validVirtualIds, existingChildren, virtualInstances,
rootId, rootInstance
)
local result =
Matching.matchChildren(session, validVirtualIds, existingChildren, virtualInstances, rootId, rootInstance)
-- Recursively hydrate matched pairs
for _, pair in result.matched do

View file

@ -24,7 +24,7 @@ local RbxDom = require(Packages.RbxDom)
local trueEquals = require(script.Parent.trueEquals)
local UNMATCHED_PENALTY: number = 10000
local UNMATCHED_PENALTY = 10000
-- ================================================================
-- Types
@ -49,21 +49,30 @@ type MatchingSession = {
type VCache = {
props: { [string]: any },
extraProps: { string }?,
tags: { [string]: boolean }?,
attrs: { [string]: any }?,
extraProps: { string },
tags: { [string]: boolean },
attrs: { [string]: any },
childCount: number,
}
type SCache = {
instance: Instance,
props: { [string]: any },
tags: { [string]: boolean }?,
attrs: { [string]: any }?,
tags: { [string]: boolean },
attrs: { [string]: any },
children: { Instance },
childCount: number,
}
type VirtualInstance = {
Name: string,
ClassName: string,
Children: { string },
Properties: { [string]: any }?,
}
type VirtualInstances = { [string]: VirtualInstance }
type ScoredPair = {
vi: number,
si: number,
@ -82,17 +91,29 @@ local function newSession(): MatchingSession
}
end
-- ================================================================
-- Utilities
-- ================================================================
local function rawIndex(inst: any, key: string): any
return inst[key]
end
local function safeGet(inst: Instance, key: string): (boolean, any)
return pcall(rawIndex, inst :: any, key)
end
-- ================================================================
-- Pre-computation helpers (called once per instance per group)
-- ================================================================
local function cacheVirtual(vInst: any, classKeys: any): VCache
local function cacheVirtual(vInst: VirtualInstance, classKeys: any): VCache
local decoded: { [string]: any } = {}
local extraProps: { string }? = nil
local extraProps: { string } = {}
local vProps = vInst.Properties
if vProps then
for propName, encodedValue in vProps do
for propName, encodedValue in pairs(vProps) do
if propName == "Tags" or propName == "Attributes" then
continue
end
@ -106,27 +127,23 @@ local function cacheVirtual(vInst: any, classKeys: any): VCache
if pcallOk and decodeOk and value ~= nil then
decoded[propName] = value
if not classKeys.propNameSet[propName] then
if not extraProps then
extraProps = {}
end
table.insert(extraProps, propName)
end
end
end
end
local decodedTags: { [string]: boolean }? = nil
local decodedTags: { [string]: boolean } = {}
if vProps and vProps.Tags then
local ok, tags = RbxDom.EncodedValue.decode(vProps.Tags)
if ok and type(tags) == "table" then
decodedTags = {}
for _, tag in tags do
for _, tag in ipairs(tags) do
decodedTags[tag] = true
end
end
end
local decodedAttrs: { [string]: any }? = nil
local decodedAttrs: { [string]: any } = {}
if vProps and vProps.Attributes then
local ok, attrs = RbxDom.EncodedValue.decode(vProps.Attributes)
if ok and type(attrs) == "table" then
@ -143,68 +160,40 @@ local function cacheVirtual(vInst: any, classKeys: any): VCache
}
end
local function cacheStudio(studioInstance: Instance, classKeys: any, extraPropNames: { string }?): SCache
local function cacheStudio(studioInstance: Instance, classKeys: any, extraPropNames: { string }): SCache
local props: { [string]: any } = {}
for _, propName in classKeys.propNames do
local ok, value = pcall(function()
return (studioInstance :: any)[propName]
end)
if ok and value ~= nil then
for _, propName in ipairs(classKeys.propNames) do
local ok, value = safeGet(studioInstance, propName)
if ok then
props[propName] = value
end
end
if extraPropNames then
for _, propName in extraPropNames do
for _, propName in ipairs(extraPropNames) do
if props[propName] ~= nil then
continue
end
local ok, value = pcall(function()
return (studioInstance :: any)[propName]
end)
if ok and value ~= nil then
local ok, value = safeGet(studioInstance, propName)
if ok then
props[propName] = value
end
end
end
local tags: { [string]: boolean }? = nil
local tagsOk, studioTags = pcall(function()
return studioInstance:GetTags()
end)
if tagsOk then
tags = {}
for _, tag in studioTags do
local tags: { [string]: boolean } = {}
for _, tag in ipairs(studioInstance:GetTags()) do
tags[tag] = true
end
end
local attrs: { [string]: any }? = nil
local attrsOk, studioAttrs = pcall(function()
return studioInstance:GetAttributes()
end)
if attrsOk then
attrs = studioAttrs
end
local children: { Instance } = {}
local childCount: number = 0
local childOk, studioKids = pcall(function()
return studioInstance:GetChildren()
end)
if childOk then
children = studioKids
childCount = #studioKids
end
local children = studioInstance:GetChildren()
return {
instance = studioInstance,
props = props,
tags = tags,
attrs = attrs,
attrs = studioInstance:GetAttributes(),
children = children,
childCount = childCount,
childCount = #children,
}
end
@ -213,12 +202,12 @@ end
-- ================================================================
local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: any): number
local cost: number = 0
local cost = 0
local vProps = vCache.props
local sProps = sCache.props
local defaults = classKeys.defaults
for _, propName in classKeys.propNames do
for _, propName in ipairs(classKeys.propNames) do
local vVal = vProps[propName]
if vVal == nil then
vVal = defaults[propName]
@ -229,43 +218,37 @@ local function countOwnDiffs(vCache: VCache, sCache: SCache, classKeys: any): nu
end
end
if vCache.extraProps then
for _, propName in vCache.extraProps do
for _, propName in ipairs(vCache.extraProps) do
if not trueEquals(vProps[propName], sProps[propName]) then
cost += 1
end
end
end
local vTags = vCache.tags
local sTags = sCache.tags
if vTags and sTags then
for tag in vTags do
for tag, _ in pairs(vTags) do
if not sTags[tag] then
cost += 1
end
end
for tag in sTags do
for tag, _ in pairs(sTags) do
if not vTags[tag] then
cost += 1
end
end
end
local vAttrs = vCache.attrs
local sAttrs = sCache.attrs
if vAttrs and sAttrs then
for key, vVal in vAttrs do
for key, vVal in pairs(vAttrs) do
if not trueEquals(vVal, sAttrs[key]) then
cost += 1
end
end
for key in sAttrs do
for key, _ in pairs(sAttrs) do
if vAttrs[key] == nil then
cost += 1
end
end
end
if vCache.childCount ~= sCache.childCount then
cost += 1
@ -279,7 +262,7 @@ end
-- ================================================================
local function removeMatched(arr: { any }, matchedIndices: { [number]: boolean }): ()
local write: number = 1
local write = 1
for read = 1, #arr do
if not matchedIndices[read] then
arr[write] = arr[read]
@ -301,7 +284,7 @@ local function computePairCost(
session: MatchingSession,
virtualId: string,
studioInstance: Instance,
virtualInstances: { [string]: any },
virtualInstances: VirtualInstances,
bestSoFar: number
): number
local vc = session.costCache[virtualId]
@ -321,7 +304,7 @@ local function computePairCost(
local vCache = cacheVirtual(vInst, classKeys)
local sCache = cacheStudio(studioInstance, classKeys, vCache.extraProps)
local cost: number = countOwnDiffs(vCache, sCache, classKeys)
local cost = countOwnDiffs(vCache, sCache, classKeys)
if cost >= bestSoFar then
return cost
end
@ -334,23 +317,21 @@ local function computePairCost(
elseif not vChildren or #vChildren == 0 then
cost += #studioKids * UNMATCHED_PENALTY
elseif #studioKids == 0 then
for _, childId in vChildren do
for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then
cost += UNMATCHED_PENALTY
end
end
else
local validVChildren: { string } = {}
for _, childId in vChildren do
for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then
table.insert(validVChildren, childId)
end
end
if #validVChildren > 0 then
local childResult: MatchResult = matchChildren(
session, validVChildren, studioKids, virtualInstances,
virtualId, studioInstance
)
local childResult =
matchChildren(session, validVChildren, studioKids, virtualInstances, virtualId, studioInstance)
cost += childResult.totalCost
else
cost += #studioKids * UNMATCHED_PENALTY
@ -371,7 +352,7 @@ matchChildren = function(
session: MatchingSession,
virtualChildren: { string },
studioChildren: { Instance },
virtualInstances: { [string]: any },
virtualInstances: VirtualInstances,
parentVirtualId: string?,
parentStudioInstance: Instance?
): MatchResult
@ -394,10 +375,10 @@ matchChildren = function(
-- Group by (Name, ClassName) -- direct property access, no pcall
-- ============================================================
local vByKey: { [string]: { number } } = {}
for i, id in remainingVirtual do
for i, id in ipairs(remainingVirtual) do
local vInst = virtualInstances[id]
if vInst then
local key: string = vInst.Name .. "\0" .. vInst.ClassName
local key = vInst.Name .. "\0" .. vInst.ClassName
local group = vByKey[key]
if not group then
group = {}
@ -408,8 +389,8 @@ matchChildren = function(
end
local sByKey: { [string]: { number } } = {}
for i, inst in remainingStudio do
local key: string = inst.Name .. "\0" .. inst.ClassName
for i, inst in ipairs(remainingStudio) do
local key = inst.Name .. "\0" .. inst.ClassName
local group = sByKey[key]
if not group then
group = {}
@ -424,7 +405,7 @@ matchChildren = function(
local matchedV: { [number]: boolean } = {}
local matchedS: { [number]: boolean } = {}
for key, vIndices in vByKey do
for key, vIndices in pairs(vByKey) do
local sIndices = sByKey[key]
if sIndices and #vIndices == 1 and #sIndices == 1 then
local vi, si = vIndices[1], sIndices[1]
@ -447,10 +428,10 @@ matchChildren = function(
-- ============================================================
if #remainingVirtual > 0 and #remainingStudio > 0 then
local vByKey2: { [string]: { number } } = {}
for i, id in remainingVirtual do
for i, id in ipairs(remainingVirtual) do
local vInst = virtualInstances[id]
if vInst then
local key: string = vInst.Name .. "\0" .. vInst.ClassName
local key = vInst.Name .. "\0" .. vInst.ClassName
local group = vByKey2[key]
if not group then
group = {}
@ -461,8 +442,8 @@ matchChildren = function(
end
local sByKey2: { [string]: { number } } = {}
for i, inst in remainingStudio do
local key: string = inst.Name .. "\0" .. inst.ClassName
for i, inst in ipairs(remainingStudio) do
local key = inst.Name .. "\0" .. inst.ClassName
local group = sByKey2[key]
if not group then
group = {}
@ -474,7 +455,7 @@ matchChildren = function(
local matchedV2: { [number]: boolean } = {}
local matchedS2: { [number]: boolean } = {}
for key, vIndices in vByKey2 do
for key, vIndices in pairs(vByKey2) do
local sIndices = sByKey2[key]
if not sIndices then
continue
@ -487,8 +468,8 @@ matchChildren = function(
local classKeys = RbxDom.getClassComparisonKeys(firstVInst.ClassName)
local vCaches: { [number]: VCache } = {}
local allExtraProps: { [string]: boolean }? = nil
for _, vi in vIndices do
local allExtraProps: { [string]: boolean } = {}
for _, vi in ipairs(vIndices) do
if matchedV2[vi] then
continue
end
@ -498,26 +479,18 @@ matchChildren = function(
end
local vCache = cacheVirtual(vInst, classKeys)
vCaches[vi] = vCache
if vCache.extraProps then
if not allExtraProps then
allExtraProps = {}
end
for _, propName in vCache.extraProps do
for _, propName in ipairs(vCache.extraProps) do
allExtraProps[propName] = true
end
end
end
local extraPropNamesArray: { string }? = nil
if allExtraProps then
extraPropNamesArray = {}
for propName in allExtraProps do
local extraPropNamesArray: { string } = {}
for propName, _ in pairs(allExtraProps) do
table.insert(extraPropNamesArray, propName)
end
end
local sCaches: { [number]: SCache } = {}
for _, si in sIndices do
for _, si in ipairs(sIndices) do
if not matchedS2[si] then
sCaches[si] = cacheStudio(remainingStudio[si], classKeys, extraPropNamesArray)
end
@ -525,10 +498,10 @@ matchChildren = function(
-- Score all (A, B) pairs
local scoredPairs: { ScoredPair } = {}
local pairIdx: number = 0
local bestSoFar: number = math.huge
local pairIdx = 0
local bestSoFar = math.huge
for _, vi in vIndices do
for _, vi in ipairs(vIndices) do
if matchedV2[vi] then
continue
end
@ -537,7 +510,7 @@ matchChildren = function(
continue
end
for _, si in sIndices do
for _, si in ipairs(sIndices) do
if matchedS2[si] then
continue
end
@ -548,7 +521,7 @@ matchChildren = function(
pairIdx += 1
local cost: number = countOwnDiffs(vCache, sCache, classKeys)
local cost = countOwnDiffs(vCache, sCache, classKeys)
if cost < bestSoFar then
local vInst = virtualInstances[remainingVirtual[vi]]
@ -561,22 +534,26 @@ matchChildren = function(
elseif not vChildren or #vChildren == 0 then
cost += #studioKids * UNMATCHED_PENALTY
elseif #studioKids == 0 then
for _, childId in vChildren do
for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then
cost += UNMATCHED_PENALTY
end
end
else
local validVChildren: { string } = {}
for _, childId in vChildren do
for _, childId in ipairs(vChildren) do
if virtualInstances[childId] then
table.insert(validVChildren, childId)
end
end
if #validVChildren > 0 then
local childResult: MatchResult = matchChildren(
session, validVChildren, studioKids, virtualInstances,
remainingVirtual[vi], sCache.instance
local childResult = matchChildren(
session,
validVChildren,
studioKids,
virtualInstances,
remainingVirtual[vi],
sCache.instance
)
cost += childResult.totalCost
else
@ -600,7 +577,7 @@ matchChildren = function(
return a.idx < b.idx
end)
for _, pair in scoredPairs do
for _, pair in ipairs(scoredPairs) do
if matchedV2[pair.vi] or matchedS2[pair.si] then
continue
end
@ -620,15 +597,13 @@ matchChildren = function(
-- ============================================================
-- Compute totalCost for ALL matched pairs (session cache helps)
-- ============================================================
local totalCost: number = 0
for _, pair in matched do
totalCost += computePairCost(
session, pair.virtualId, pair.studioInstance, virtualInstances, math.huge
)
local totalCost = 0
for _, pair in ipairs(matched) do
totalCost += computePairCost(session, pair.virtualId, pair.studioInstance, virtualInstances, math.huge)
end
totalCost += (#remainingVirtual + #remainingStudio) * UNMATCHED_PENALTY
local result: MatchResult = {
local result = {
matched = matched,
unmatchedVirtual = remainingVirtual,
unmatchedStudio = remainingStudio,

View file

@ -19,9 +19,7 @@ local function fuzzyEq(a: number, b: number): boolean
end
local function color3Eq(a: Color3, b: Color3): boolean
return math.floor(a.R * 255) == math.floor(b.R * 255)
and math.floor(a.G * 255) == math.floor(b.G * 255)
and math.floor(a.B * 255) == math.floor(b.B * 255)
return fuzzyEq(a.R, b.R) and fuzzyEq(a.G, b.G) and fuzzyEq(a.B, b.B)
end
local function vector2Eq(a: Vector2, b: Vector2): boolean
@ -35,10 +33,18 @@ end
local function cframeEq(a: CFrame, b: CFrame): boolean
local ax, ay, az, aR00, aR01, aR02, aR10, aR11, aR12, aR20, aR21, aR22 = a:GetComponents()
local bx, by, bz, bR00, bR01, bR02, bR10, bR11, bR12, bR20, bR21, bR22 = b:GetComponents()
return fuzzyEq(ax, bx) and fuzzyEq(ay, by) and fuzzyEq(az, bz)
and fuzzyEq(aR00, bR00) and fuzzyEq(aR01, bR01) and fuzzyEq(aR02, bR02)
and fuzzyEq(aR10, bR10) and fuzzyEq(aR11, bR11) and fuzzyEq(aR12, bR12)
and fuzzyEq(aR20, bR20) and fuzzyEq(aR21, bR21) and fuzzyEq(aR22, bR22)
return fuzzyEq(ax, bx)
and fuzzyEq(ay, by)
and fuzzyEq(az, bz)
and fuzzyEq(aR00, bR00)
and fuzzyEq(aR01, bR01)
and fuzzyEq(aR02, bR02)
and fuzzyEq(aR10, bR10)
and fuzzyEq(aR11, bR11)
and fuzzyEq(aR12, bR12)
and fuzzyEq(aR20, bR20)
and fuzzyEq(aR21, bR21)
and fuzzyEq(aR22, bR22)
end
local function trueEquals(a: any, b: any): boolean
@ -54,11 +60,12 @@ local function trueEquals(a: any, b: any): boolean
end
local t = typeof(a)
if t ~= typeof(b) then
if t == "number" and typeof(b) == "EnumItem" then
local tb = typeof(b)
if t ~= tb then
if t == "number" and tb == "EnumItem" then
return a == (b :: EnumItem).Value
end
if t == "EnumItem" and typeof(b) == "number" then
if t == "EnumItem" and tb == "number" then
return (a :: EnumItem).Value == b
end
return false

View file

@ -1350,6 +1350,13 @@ impl JobThreadContext {
Some(new_dir_path.join(file_name));
new_ref_segment =
Some(slugified_new_name.clone());
if patch_set.stage_ids.contains(&id) {
let old_init = dir_path.join(file_name);
pending_stage_paths
.retain(|p| *p != old_init);
pending_stage_paths
.push(new_dir_path.join(file_name));
}
let old_meta = grandparent.join(format!(
"{}.meta.json5",
dir_name
@ -1511,6 +1518,10 @@ impl JobThreadContext {
} else {
overridden_source_path = Some(new_path.clone());
new_ref_segment = Some(new_file_name.clone());
if patch_set.stage_ids.contains(&id) {
pending_stage_paths.retain(|p| *p != *path);
pending_stage_paths.push(new_path.clone());
}
let old_meta = parent
.join(format!("{}.meta.json5", old_base));
let new_meta = parent.join(format!(
@ -1689,6 +1700,11 @@ impl JobThreadContext {
);
} else {
overridden_source_path = Some(new_path.clone());
if patch_set.stage_ids.contains(&id) {
pending_stage_paths
.retain(|p| *p != actual_file);
pending_stage_paths.push(new_path.clone());
}
// For standalone files, the ref path
// segment changes with the extension.
// For init files, the directory name

View file

@ -72,8 +72,11 @@ impl ServeCommand {
loop {
let (vfs, critical_errors) = Vfs::new_default_with_errors();
let session =
Arc::new(ServeSession::new(vfs, project_path.clone(), Some(critical_errors))?);
let session = Arc::new(ServeSession::new(
vfs,
project_path.clone(),
Some(critical_errors),
)?);
let server = LiveServer::new(session);
log::info!("Listening: http://{}:{}", host, port);
@ -94,7 +97,7 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R
let new_dom = build_dom_from_chunks(payload)?;
let vfs = Vfs::new_oneshot();
let session_old = ServeSession::new_oneshot(vfs, project_path.to_path_buf())?;
let session_old = ServeSession::new_oneshot(vfs, project_path)?;
let mut dom_old = session_old.tree();
@ -149,8 +152,8 @@ fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result<WeakDom> {
created_services.insert(chunk.class_name.clone());
if !chunk.data.is_empty() {
let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data))
.with_context(|| {
let chunk_dom =
rbx_binary::from_reader(Cursor::new(&chunk.data)).with_context(|| {
format!("Failed to parse rbxm for service {}", chunk.class_name)
})?;

View file

@ -33,6 +33,12 @@ pub struct MatchingSession {
cost_cache: RefCell<HashMap<(Ref, Ref), u32>>,
}
impl Default for MatchingSession {
fn default() -> Self {
Self::new()
}
}
impl MatchingSession {
pub fn new() -> Self {
Self {
@ -116,7 +122,14 @@ pub fn match_forward(
let snap_remaining = snap_matched.iter().filter(|&&m| !m).count();
let tree_remaining = tree_available.iter().filter(|&&a| a).count();
if snap_remaining == 0 || tree_remaining == 0 {
return build_result(snap_available, tree_children, &tree_available, matched, tree, session);
return build_result(
snap_available,
tree_children,
&tree_available,
matched,
tree,
session,
);
}
// ================================================================
@ -186,7 +199,14 @@ pub fn match_forward(
}
}
build_result(snap_available, tree_children, &tree_available, matched, tree, session)
build_result(
snap_available,
tree_children,
&tree_available,
matched,
tree,
session,
)
}
/// Build the final result, consuming the snapshot children.
@ -1139,7 +1159,12 @@ mod tests {
make_snapshot_with_props("Wall", "Part", tree_props_b),
make_snapshot_with_props("Wall", "Part", tree_props_a),
]);
let result = match_forward(vec![snap_a, snap_b], &tree_refs, &tree, &MatchingSession::new());
let result = match_forward(
vec![snap_a, snap_b],
&tree_refs,
&tree,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2, "Both should match");

View file

@ -235,7 +235,14 @@ fn compute_children_patches(
// Matched pairs: recursively compute patches.
for (snapshot_child, tree_child_id) in match_result.matched {
compute_patch_set_internal(context, snapshot_child, tree, tree_child_id, patch_set, session);
compute_patch_set_internal(
context,
snapshot_child,
tree,
tree_child_id,
patch_set,
session,
);
}
// Unmatched snapshots: new instances to be added.

View file

@ -30,6 +30,12 @@ pub struct MatchingSession {
cost_cache: RefCell<HashMap<(Ref, Ref), u32>>,
}
impl Default for MatchingSession {
fn default() -> Self {
Self::new()
}
}
impl MatchingSession {
pub fn new() -> Self {
Self {
@ -122,12 +128,19 @@ pub fn match_children(
let mut total_cost: u32 = 0;
for &(new_ref, old_ref) in &matched {
total_cost = total_cost.saturating_add(compute_change_count(
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, u32::MAX, 0, session,
new_ref,
old_ref,
new_dom,
old_dom,
new_hashes,
old_hashes,
u32::MAX,
0,
session,
));
}
total_cost = total_cost.saturating_add(
(remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY,
);
total_cost = total_cost
.saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY);
return MatchResult {
matched,
unmatched_new: remaining_new,
@ -178,7 +191,14 @@ pub fn match_children(
for &new_ref in &avail_new {
for &old_ref in &avail_old {
let cost = compute_change_count(
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, best_so_far, 0,
new_ref,
old_ref,
new_dom,
old_dom,
new_hashes,
old_hashes,
best_so_far,
0,
session,
);
pairs.push((cost, new_ref, old_ref));
@ -208,12 +228,19 @@ pub fn match_children(
let mut total_cost: u32 = 0;
for &(new_ref, old_ref) in &matched {
total_cost = total_cost.saturating_add(compute_change_count(
new_ref, old_ref, new_dom, old_dom, new_hashes, old_hashes, u32::MAX, 0, session,
new_ref,
old_ref,
new_dom,
old_dom,
new_hashes,
old_hashes,
u32::MAX,
0,
session,
));
}
total_cost = total_cost.saturating_add(
(remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY,
);
total_cost = total_cost
.saturating_add((remaining_new.len() + remaining_old.len()) as u32 * UNMATCHED_PENALTY);
MatchResult {
matched,
@ -545,7 +572,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new());
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2);
assert!(result.unmatched_new.is_empty());
@ -585,7 +620,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new());
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2);
for (new_ref, old_ref) in &result.matched {
@ -616,7 +659,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new());
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 1);
assert_eq!(result.unmatched_new.len(), 1);
@ -638,7 +689,15 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let result = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new());
let result = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(result.matched.len(), 2);
assert!(result.unmatched_new.is_empty());
assert!(result.unmatched_old.is_empty());
@ -648,7 +707,15 @@ mod tests {
fn empty_children() {
let new_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let old_dom = WeakDom::new(InstanceBuilder::new("DataModel"));
let result = match_children(&[], &[], &new_dom, &old_dom, None, None, &MatchingSession::new());
let result = match_children(
&[],
&[],
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert!(result.matched.is_empty());
}
@ -667,8 +734,24 @@ mod tests {
let new_children: Vec<Ref> = new_dom.get_by_ref(new_root).unwrap().children().to_vec();
let old_children: Vec<Ref> = old_dom.get_by_ref(old_root).unwrap().children().to_vec();
let r1 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new());
let r2 = match_children(&new_children, &old_children, &new_dom, &old_dom, None, None, &MatchingSession::new());
let r1 = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
let r2 = match_children(
&new_children,
&old_children,
&new_dom,
&old_dom,
None,
None,
&MatchingSession::new(),
);
assert_eq!(r1.matched.len(), r2.matched.len());
for (a, b) in r1.matched.iter().zip(r2.matched.iter()) {

View file

@ -29,9 +29,9 @@ use crate::{
web::{
interface::{
ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse,
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType,
SubscribeMessage, SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse,
PROTOCOL_VERSION, SERVER_VERSION,
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse, PROTOCOL_VERSION,
SERVER_VERSION,
},
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
},
@ -256,7 +256,7 @@ async fn handle_api_syncback(
};
syncback_signal.fire(payload);
msgpack_ok(&serde_json::json!({"status": "syncback_initiated"}))
msgpack_ok(serde_json::json!({"status": "syncback_initiated"}))
}
pub struct ApiService {

View file

@ -41,12 +41,15 @@ impl SyncbackSignal {
}
pub fn fire(&self, payload: SyncbackPayload) {
*self.payload.lock().unwrap() = Some(payload);
*self.payload.lock().unwrap_or_else(|e| e.into_inner()) = Some(payload);
self.notify.notify_one();
}
pub fn take_payload(&self) -> Option<SyncbackPayload> {
self.payload.lock().unwrap().take()
self.payload
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
}
}

View file

@ -505,8 +505,10 @@ pub fn make_rbxl_from_chunks(chunks: &[librojo::web_api::ServiceChunk]) -> Vec<u
let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data))
.unwrap_or_else(|e| panic!("Failed to parse rbxm for {}: {e}", chunk.class_name));
let service_ref =
dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new(&chunk.class_name));
let service_ref = dom.insert(
root_ref,
rbx_dom_weak::InstanceBuilder::new(&chunk.class_name),
);
let mut ref_map: HashMap<Ref, Ref> = HashMap::new();
for &child_ref in chunk_dom.root().children() {
@ -554,10 +556,8 @@ pub fn make_rbxl_from_chunks(chunks: &[librojo::web_api::ServiceChunk]) -> Vec<u
"VoiceChatService",
"Workspace",
];
let existing: std::collections::HashSet<String> = chunks
.iter()
.map(|c| c.class_name.clone())
.collect();
let existing: std::collections::HashSet<String> =
chunks.iter().map(|c| c.class_name.clone()).collect();
for &svc in &visible_services {
if !existing.contains(svc) {
dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new(svc));

View file

@ -899,3 +899,53 @@ fn batch_mixed_stage_and_source_write() {
);
}
}
// ===========================================================================
// stage_ids survives rename (Fix 2 from audit)
// ===========================================================================
#[test]
fn stage_ids_stages_new_path_after_rename() {
let mut session = TestServeSession::new_with_git("git_sync_defaults", |path| {
git_commit_all(path, "initial commit");
});
let info = session.wait_to_come_online();
let read = session.get_api_read(info.root_instance_id).unwrap();
let (module_id, _) =
find_instance_by_name(&read.instances, "ModuleA").expect("ModuleA should exist");
let old_path = session.path().join("src/ModuleA.luau");
let new_path = session.path().join("src/RenamedModule.luau");
assert!(old_path.exists(), "ModuleA.luau should exist before rename");
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![InstanceUpdate {
id: module_id,
changed_name: Some("RenamedModule".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
}],
stage_ids: vec![module_id],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(1000));
assert!(
new_path.exists(),
"RenamedModule.luau should exist after rename"
);
assert!(
!old_path.exists(),
"ModuleA.luau should be gone after rename"
);
assert!(
git_is_staged(session.path(), "src/RenamedModule.luau"),
"New file (RenamedModule.luau) should be staged after rename + stage_ids"
);
}

View file

@ -9,11 +9,7 @@ use crate::rojo_test::{
use librojo::web_api::{ServiceChunk, SocketPacketType, PROTOCOL_VERSION};
fn assert_live_matches_cli(
fixture_name: &str,
chunks: &[ServiceChunk],
place_id: Option<u64>,
) {
fn assert_live_matches_cli(fixture_name: &str, chunks: &[ServiceChunk], place_id: Option<u64>) {
run_serve_test(fixture_name, |session, _| {
session.post_api_syncback(place_id, chunks.to_vec());
session.wait_to_come_back_online();
@ -32,10 +28,16 @@ fn parity_scripts() {
vec![
InstanceBuilder::new("ModuleScript")
.with_name("Utils")
.with_property("Source", rbx_dom_weak::types::Variant::String("return {}".into())),
.with_property(
"Source",
rbx_dom_weak::types::Variant::String("return {}".into()),
),
InstanceBuilder::new("Script")
.with_name("Main")
.with_property("Source", rbx_dom_weak::types::Variant::String("print('hi')".into()))
.with_property(
"Source",
rbx_dom_weak::types::Variant::String("print('hi')".into()),
)
.with_property(
"RunContext",
rbx_dom_weak::types::Variant::Enum(rbx_dom_weak::types::Enum::from_u32(1)),
@ -117,7 +119,10 @@ fn parity_mixed_file_types() {
vec![
InstanceBuilder::new("StringValue")
.with_name("MyString")
.with_property("Value", rbx_dom_weak::types::Variant::String("hello world".into())),
.with_property(
"Value",
rbx_dom_weak::types::Variant::String("hello world".into()),
),
InstanceBuilder::new("Folder").with_name("Container"),
InstanceBuilder::new("ModuleScript")
.with_name("DataModule")

View file

@ -309,7 +309,8 @@ fn forward_matching_ufowave_textures() {
// Run forward sync matching
let session = librojo::snapshot::matching::MatchingSession::new();
let result = librojo::snapshot::matching::match_forward(snap_textures, &tree_textures, &tree, &session);
let result =
librojo::snapshot::matching::match_forward(snap_textures, &tree_textures, &tree, &session);
assert_eq!(
result.matched.len(),