Update syncback feature to handle instance pulling from Studio

- Updated the `ServeSession` to implement logic for pulling instances that exist in Studio but not in Rojo's filesystem, creating appropriate files during sync confirmation.
- Introduced a new `encodeInstance` function to serialize Studio instances for transmission, supporting all instance types.
- Modified the `PatchTree` to allow explicit user selection for push/pull/skip actions, enhancing user control during sync operations.
- Added tests for the new encoding functionality and updated existing tests to ensure proper behavior with the new syncback logic.
This commit is contained in:
Joe 2026-01-30 07:18:10 -06:00
parent ace839e46f
commit c7cddb72c2
15 changed files with 3534 additions and 39 deletions

View file

@ -0,0 +1,132 @@
--[[
Encodes a Roblox Studio instance into a format suitable for transmission
to the Rojo server for syncback (creating files from Studio instances).
Supports ALL instance types:
- Scripts (ModuleScript, Script, LocalScript) -> .luau files or directories with init files if has children
- Folders -> directories (with Attributes if present)
- All other instances -> .model.json files or directories
Children are recursively encoded and included.
]]
local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local RbxDom = require(Packages.RbxDom)
local encodeProperty = require(script.Parent.encodeProperty)
-- Script class names that need Source property
local SCRIPT_CLASS_NAMES = {
Script = true,
LocalScript = true,
ModuleScript = true,
}
-- Properties to skip when encoding (internal Roblox properties)
local SKIP_PROPERTIES = {
Parent = true,
Name = true, -- We handle this separately
Archivable = true,
}
-- Classes that become directories but may have properties (like Attributes)
local DIRECTORY_CLASSES = {
Folder = true,
Configuration = true,
Tool = true,
ScreenGui = true,
SurfaceGui = true,
BillboardGui = true,
AdGui = true,
}
-- Forward declaration for recursion
local encodeInstance
-- Encode Attributes if present on any instance
local function encodeAttributes(instance, properties)
-- Try to get Attributes - this works for all instance types
local success, attributes = pcall(function()
return instance:GetAttributes()
end)
if success and attributes and next(attributes) then
-- Attributes need to be encoded specially
-- The server expects them in the properties map under "Attributes"
local attributeDescriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, "Attributes")
if attributeDescriptor then
local encodeSuccess, encodeResult = encodeProperty(instance, "Attributes", attributeDescriptor)
if encodeSuccess and encodeResult ~= nil then
properties.Attributes = encodeResult
end
end
end
end
encodeInstance = function(instance, parentId)
local properties = {}
-- Always try to encode Attributes for any instance type
encodeAttributes(instance, properties)
if SCRIPT_CLASS_NAMES[instance.ClassName] then
-- For scripts, encode the Source property
local sourceDescriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, "Source")
if sourceDescriptor then
local encodeSuccess, encodeResult = encodeProperty(instance, "Source", sourceDescriptor)
if encodeSuccess then
properties.Source = encodeResult
else
Log.warn("Failed to encode Source property for {:?}", instance)
return nil
end
else
Log.warn("Could not find Source property descriptor for {:?}", instance)
return nil
end
elseif not DIRECTORY_CLASSES[instance.ClassName] then
-- For non-directory, non-script classes, encode all relevant properties
local classDescriptor = RbxDom.findClassDescriptor(instance.ClassName)
if classDescriptor then
for propertyName, propertyDescriptor in classDescriptor.properties do
if SKIP_PROPERTIES[propertyName] then
continue
end
-- Skip Attributes here since we handle it separately above
if propertyName == "Attributes" then
continue
end
-- Only encode serializable properties
if propertyDescriptor.scriptability == "ReadWrite" or propertyDescriptor.scriptability == "Read" then
local encodeSuccess, encodeResult = encodeProperty(instance, propertyName, propertyDescriptor)
if encodeSuccess and encodeResult ~= nil then
properties[propertyName] = encodeResult
end
end
end
end
end
-- For directory classes (Folder, Configuration, etc.), we only encode Attributes (done above)
-- Recursively encode children
local children = {}
for _, child in ipairs(instance:GetChildren()) do
local encodedChild = encodeInstance(child, nil) -- parentId will be resolved server-side
if encodedChild then
table.insert(children, encodedChild)
end
end
return {
parent = parentId,
name = instance.Name,
className = instance.ClassName,
properties = properties,
children = children, -- Include children array (may be empty)
}
end
return encodeInstance

View file

@ -0,0 +1,160 @@
return function()
local encodeInstance = require(script.Parent.encodeInstance)
describe("encodeInstance", function()
describe("script encoding", function()
it("should encode ModuleScript correctly", function()
local moduleScript = Instance.new("ModuleScript")
moduleScript.Name = "TestModule"
moduleScript.Source = "return {}"
local encoded = encodeInstance(moduleScript, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.parent).to.equal("PARENT_ID")
expect(encoded.name).to.equal("TestModule")
expect(encoded.className).to.equal("ModuleScript")
expect(encoded.properties).to.be.ok()
expect(encoded.properties.Source).to.be.ok()
moduleScript:Destroy()
end)
it("should encode Script correctly", function()
local script = Instance.new("Script")
script.Name = "TestScript"
script.Source = "print('hello')"
local encoded = encodeInstance(script, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.name).to.equal("TestScript")
expect(encoded.className).to.equal("Script")
expect(encoded.properties.Source).to.be.ok()
script:Destroy()
end)
it("should encode LocalScript correctly", function()
local localScript = Instance.new("LocalScript")
localScript.Name = "TestClient"
localScript.Source = "print('client')"
local encoded = encodeInstance(localScript, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.name).to.equal("TestClient")
expect(encoded.className).to.equal("LocalScript")
expect(encoded.properties.Source).to.be.ok()
localScript:Destroy()
end)
end)
describe("folder encoding", function()
it("should encode Folder correctly", function()
local folder = Instance.new("Folder")
folder.Name = "TestFolder"
local encoded = encodeInstance(folder, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.parent).to.equal("PARENT_ID")
expect(encoded.name).to.equal("TestFolder")
expect(encoded.className).to.equal("Folder")
-- Folders have no special properties
expect(encoded.properties).to.be.ok()
folder:Destroy()
end)
end)
describe("other instance encoding", function()
it("should encode Part with properties", function()
local part = Instance.new("Part")
part.Name = "TestPart"
part.Anchored = true
local encoded = encodeInstance(part, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.name).to.equal("TestPart")
expect(encoded.className).to.equal("Part")
expect(encoded.properties).to.be.ok()
-- Properties should be captured
-- Note: Exact properties depend on RbxDom descriptors
part:Destroy()
end)
it("should encode StringValue", function()
local stringValue = Instance.new("StringValue")
stringValue.Name = "TestValue"
stringValue.Value = "Hello World"
local encoded = encodeInstance(stringValue, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.name).to.equal("TestValue")
expect(encoded.className).to.equal("StringValue")
stringValue:Destroy()
end)
it("should encode Configuration", function()
local config = Instance.new("Configuration")
config.Name = "TestConfig"
local encoded = encodeInstance(config, "PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.className).to.equal("Configuration")
config:Destroy()
end)
end)
describe("parent ID handling", function()
it("should include parent ID in all encodings", function()
local instances = {
Instance.new("ModuleScript"),
Instance.new("Folder"),
Instance.new("Part"),
}
for _, instance in instances do
local encoded = encodeInstance(instance, "UNIQUE_PARENT_ID")
expect(encoded).to.be.ok()
expect(encoded.parent).to.equal("UNIQUE_PARENT_ID")
instance:Destroy()
end
end)
end)
describe("edge cases", function()
it("should handle instances with special characters in name", function()
local folder = Instance.new("Folder")
folder.Name = "Test Folder (1)"
local encoded = encodeInstance(folder, "PARENT")
expect(encoded).to.be.ok()
expect(encoded.name).to.equal("Test Folder (1)")
folder:Destroy()
end)
it("should handle instances with empty name", function()
local folder = Instance.new("Folder")
folder.Name = ""
local encoded = encodeInstance(folder, "PARENT")
expect(encoded).to.be.ok()
expect(encoded.name).to.equal("")
folder:Destroy()
end)
end)
end)
end

View file

@ -28,6 +28,7 @@ function ChangeBatcher.new(instanceMap, onChangesFlushed)
__onChangesFlushed = onChangesFlushed,
__pendingPropertyChanges = {},
__syncSourceOnly = false,
__paused = false,
}, ChangeBatcher)
return self
@ -37,6 +38,21 @@ function ChangeBatcher:setSyncSourceOnly(enabled)
self.__syncSourceOnly = enabled
end
-- Pause the batcher to prevent change accumulation during confirmation
function ChangeBatcher:pause()
self.__paused = true
end
-- Resume the batcher after confirmation
function ChangeBatcher:resume()
self.__paused = false
end
-- Check if the batcher is paused
function ChangeBatcher:isPaused()
return self.__paused
end
function ChangeBatcher:stop()
self.__renderSteppedConnection:Disconnect()
self.__pendingPropertyChanges = {}
@ -54,6 +70,11 @@ function ChangeBatcher:add(instance, propertyName)
end
function ChangeBatcher:__cycle(dt)
-- Skip processing when paused (during confirmation dialogue)
if self.__paused then
return
end
self.__accumulator += dt
if self.__accumulator >= BATCH_INTERVAL then

View file

@ -98,4 +98,74 @@ return function()
expect(changeBatcher:__flush()).to.equal(nil)
end)
end)
describe("pause/resume", function()
it("should start unpaused by default", function()
local changeBatcher = ChangeBatcher.new(InstanceMap.new(), noop)
expect(changeBatcher:isPaused()).to.equal(false)
changeBatcher:stop()
end)
it("should be pausable", function()
local changeBatcher = ChangeBatcher.new(InstanceMap.new(), noop)
changeBatcher:pause()
expect(changeBatcher:isPaused()).to.equal(true)
changeBatcher:stop()
end)
it("should be resumable after pause", function()
local changeBatcher = ChangeBatcher.new(InstanceMap.new(), noop)
changeBatcher:pause()
expect(changeBatcher:isPaused()).to.equal(true)
changeBatcher:resume()
expect(changeBatcher:isPaused()).to.equal(false)
changeBatcher:stop()
end)
it("should not flush when paused", function()
local flushed = false
local instanceMap = InstanceMap.new()
local changeBatcher = ChangeBatcher.new(instanceMap, function()
flushed = true
end)
local part = Instance.new("Part")
instanceMap:insert("PART", part)
changeBatcher.__pendingPropertyChanges[part] = { Name = true }
changeBatcher:pause()
-- Call __cycle with a large dt to trigger flush (normally 0.2s threshold)
changeBatcher:__cycle(1.0)
-- Should not have flushed because we're paused
expect(flushed).to.equal(false)
changeBatcher:stop()
part:Destroy()
end)
it("should flush after resume", function()
local flushed = false
local instanceMap = InstanceMap.new()
local changeBatcher = ChangeBatcher.new(instanceMap, function()
flushed = true
end)
local part = Instance.new("Part")
instanceMap:insert("PART", part)
changeBatcher.__pendingPropertyChanges[part] = { Name = true }
changeBatcher:pause()
changeBatcher:__cycle(1.0)
expect(flushed).to.equal(false)
changeBatcher:resume()
changeBatcher:__cycle(1.0)
expect(flushed).to.equal(true)
changeBatcher:stop()
part:Destroy()
end)
end)
end