--[[ Methods to operate on either a patch created by the hydrate method, or a patch returned from the API. ]] local Packages = script.Parent.Parent.Packages local t = require(Packages.t) local Types = require(script.Parent.Types) local decodeValue = require(script.Parent.Reconciler.decodeValue) local function deepEqual(a: any, b: any): boolean local typeA = typeof(a) if typeA ~= typeof(b) then return false end if typeof(a) == "table" then local checkedKeys = {} for key, value in a do checkedKeys[key] = true if deepEqual(value, b[key]) == false then return false end end for key, value in b do if checkedKeys[key] then continue end if deepEqual(value, a[key]) == false then return false end end return true end if a == b then return true end return false end local PatchSet = {} PatchSet.validate = t.interface({ removed = t.array(t.union(Types.RbxId, t.Instance)), added = t.map(Types.RbxId, Types.ApiInstance), updated = t.array(Types.ApiInstanceUpdate), }) --[[ Create a new, empty PatchSet. ]] function PatchSet.newEmpty() return { removed = {}, added = {}, updated = {}, } end --[[ Tells whether the given PatchSet is empty. ]] function PatchSet.isEmpty(patchSet) return next(patchSet.removed) == nil and next(patchSet.added) == nil and next(patchSet.updated) == nil end --[[ Tells whether the given PatchSet has any remove operations. ]] function PatchSet.hasRemoves(patchSet) return next(patchSet.removed) ~= nil end --[[ Tells whether the given PatchSet has any add operations. ]] function PatchSet.hasAdditions(patchSet) return next(patchSet.added) ~= nil end --[[ Tells whether the given PatchSet has any update operations. ]] function PatchSet.hasUpdates(patchSet) return next(patchSet.updated) ~= nil end --[[ Tells whether the given PatchSet contains changes to the given instance id ]] function PatchSet.containsId(patchSet, instanceMap, id) if patchSet.added[id] ~= nil then return true end for _, idOrInstance in patchSet.removed do local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance] if removedId == id then return true end end for _, update in patchSet.updated do if update.id == id then return true end end return false end --[[ Tells whether the given PatchSet contains changes to the given instance. If the given InstanceMap does not contain the instance, this function always returns false. ]] function PatchSet.containsInstance(patchSet, instanceMap, instance) local id = instanceMap.fromInstances[instance] if id == nil then return false end return PatchSet.containsId(patchSet, instanceMap, id) end --[[ Tells whether the given PatchSet contains changes to nothing but the given instance id ]] function PatchSet.containsOnlyId(patchSet, instanceMap, id) if not PatchSet.containsId(patchSet, instanceMap, id) then -- Patch doesn't contain the id at all return false end for addedId in patchSet.added do if addedId ~= id then return false end end for _, idOrInstance in patchSet.removed do local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance] if removedId ~= id then return false end end for _, update in patchSet.updated do if update.id ~= id then return false end end return true end --[[ Tells whether the given PatchSet contains changes to nothing but the given instance. If the given InstanceMap does not contain the instance, this function always returns false. ]] function PatchSet.containsOnlyInstance(patchSet, instanceMap, instance) local id = instanceMap.fromInstances[instance] if id == nil then return false end return PatchSet.containsOnlyId(patchSet, instanceMap, id) end --[[ Returns the update to the given instance id, or nil if there aren't any ]] function PatchSet.getUpdateForId(patchSet, id) for _, update in patchSet.updated do if update.id == id then return update end end return nil end --[[ Returns the update to the given instance, or nil if there aren't any. If the given InstanceMap does not contain the instance, this function always returns nil. ]] function PatchSet.getUpdateForInstance(patchSet, instanceMap, instance) local id = instanceMap.fromInstances[instance] if id == nil then return nil end return PatchSet.getUpdateForId(patchSet, id) end --[[ Tells whether the given PatchSets are equal. ]] function PatchSet.isEqual(patchA, patchB) return deepEqual(patchA, patchB) end --[[ Count the number of changes in the given PatchSet. ]] function PatchSet.countChanges(patch) local count = 0 for _, add in patch.added do -- Adding an instance is 1 change per property for _ in add.Properties do count += 1 end end for _ in patch.removed do -- Removing an instance is 1 change count += 1 end for _, update in patch.updated do -- Updating an instance is 1 change per property updated for _ in update.changedProperties do count += 1 end if update.changedName ~= nil then count += 1 end if update.changedClassName ~= nil then count += 1 end end return count end --[[ Count the number of instances affected by the given PatchSet. ]] function PatchSet.countInstances(patch) local count = 0 -- Added instances for _ in patch.added do count += 1 end -- Removed instances for _ in patch.removed do count += 1 end -- Updated instances for _ in patch.updated do count += 1 end return count end --[[ Merge multiple PatchSet objects into the given PatchSet. ]] function PatchSet.assign(target, ...) for i = 1, select("#", ...) do local sourcePatch = select(i, ...) for _, removed in ipairs(sourcePatch.removed) do table.insert(target.removed, removed) end for id, added in pairs(sourcePatch.added) do target.added[id] = added end for _, update in ipairs(sourcePatch.updated) do table.insert(target.updated, update) end end return target end --[[ Merge a source PatchSet into the target, properly reconciling conflicts. Unlike assign(), this handles cases where a removal cancels out an addition, or where an update supersedes a previous update to the same instance. If instanceMap is provided, updates that revert to the current instance state will be removed from the patch entirely. ]] function PatchSet.merge(target, source, instanceMap) -- Process removals: if we're removing something that was added, just delete the addition for _, removed in ipairs(source.removed) do if target.added[removed] ~= nil then -- This removal cancels out a previous addition target.added[removed] = nil else table.insert(target.removed, removed) end end -- Process additions for id, added in pairs(source.added) do target.added[id] = added end -- Process updates: merge with existing updates to the same instance for _, update in ipairs(source.updated) do local existingIndex = nil for i, existing in ipairs(target.updated) do if existing.id == update.id then existingIndex = i break end end if existingIndex then -- Merge the updates local existing = target.updated[existingIndex] if update.changedName ~= nil then existing.changedName = update.changedName end if update.changedClassName ~= nil then existing.changedClassName = update.changedClassName end if update.changedMetadata ~= nil then existing.changedMetadata = update.changedMetadata end for prop, value in pairs(update.changedProperties) do existing.changedProperties[prop] = value end -- Check if this update now matches current instance state (i.e., reverted) -- If so, remove properties that match or remove the update entirely if instanceMap then local instance = instanceMap.fromIds[existing.id] if instance then -- Check if name change matches current if existing.changedName ~= nil and existing.changedName == instance.Name then existing.changedName = nil end -- Check if class change matches current if existing.changedClassName ~= nil and existing.changedClassName == instance.ClassName then existing.changedClassName = nil end -- Check each changed property against current instance value for propName, encodedValue in pairs(existing.changedProperties) do local decodeSuccess, decodedValue = decodeValue(encodedValue, instanceMap) if decodeSuccess then local currentSuccess, currentValue = pcall(function() return instance[propName] end) if currentSuccess and deepEqual(decodedValue, currentValue) then existing.changedProperties[propName] = nil end end end -- If no changes remain, remove the update entirely local hasChanges = existing.changedName ~= nil or existing.changedClassName ~= nil or existing.changedMetadata ~= nil or next(existing.changedProperties) ~= nil if not hasChanges then table.remove(target.updated, existingIndex) end end end else table.insert(target.updated, update) end end return target end function PatchSet.addedIdList(patchSet): { string } local idList = table.create(#patchSet.added) for id in patchSet.added do table.insert(idList, id) end return table.freeze(idList) end function PatchSet.updatedIdList(patchSet): { string } local idList = table.create(#patchSet.updated) for _, item in patchSet.updated do table.insert(idList, item.id) end return table.freeze(idList) end --[[ Remove the Name property change from the DataModel update in the patch. This prevents the project name from being synced, which is usually unwanted as Studio manages the DataModel name independently. Mutates the patch in place for efficiency. ]] function PatchSet.removeDataModelName(patchSet, instanceMap) local dataModelId = instanceMap.fromInstances[game] if dataModelId == nil then return end for i, update in ipairs(patchSet.updated) do if update.id == dataModelId then -- Remove Name from both changedName and changedProperties local hadChanges = false if update.changedName ~= nil then update.changedName = nil hadChanges = true end if update.changedProperties and update.changedProperties.Name ~= nil then update.changedProperties.Name = nil hadChanges = true end if not hadChanges then return end -- Remove update entirely if no other changes remain if update.changedName == nil and update.changedClassName == nil and update.changedMetadata == nil and (update.changedProperties == nil or next(update.changedProperties) == nil) then table.remove(patchSet.updated, i) end return end end end --[[ Create a list of human-readable statements summarizing the contents of this patch, intended to be displayed to users. ]] function PatchSet.humanSummary(instanceMap, patchSet) local statements = {} for _, idOrInstance in ipairs(patchSet.removed) do local instance, id if Types.RbxId(idOrInstance) then id = idOrInstance instance = instanceMap.fromIds[id] else instance = idOrInstance id = instanceMap.fromInstances[instance] end if instance ~= nil then table.insert(statements, string.format("- Delete instance %s", instance:GetFullName())) else table.insert(statements, string.format("- Delete instance with ID %s", id)) end end local additionsMentioned = {} local function addAllDescendents(virtualInstance) additionsMentioned[virtualInstance.Id] = true for _, childId in ipairs(virtualInstance.Children) do addAllDescendents(patchSet.added[childId]) end end for id, addition in pairs(patchSet.added) do if additionsMentioned[id] then continue end local virtualInstance = addition while true do if virtualInstance.Parent == nil then break end local virtualParent = patchSet.added[virtualInstance.Parent] if virtualParent == nil then break end virtualInstance = virtualParent end local parentDisplayName = "nil (how strange!)" if virtualInstance.Parent ~= nil then local parent = instanceMap.fromIds[virtualInstance.Parent] if parent ~= nil then parentDisplayName = parent:GetFullName() end end table.insert( statements, string.format( "- Add instance %q (ClassName %q) to %s", virtualInstance.Name, virtualInstance.ClassName, parentDisplayName ) ) end for _, update in ipairs(patchSet.updated) do local updatedProperties = {} if update.changedMetadata ~= nil then table.insert(updatedProperties, "Rojo's Metadata") end if update.changedName ~= nil then table.insert(updatedProperties, "Name") end if update.changedClassName ~= nil then table.insert(updatedProperties, "ClassName") end for name in pairs(update.changedProperties) do table.insert(updatedProperties, name) end local instance = instanceMap.fromIds[update.id] local displayName if instance ~= nil then displayName = instance:GetFullName() else displayName = "[unknown instance]" end table.insert( statements, string.format("- Update properties on %s: %s", displayName, table.concat(updatedProperties, ",")) ) end return table.concat(statements, "\n") end return PatchSet