Compare commits

...

2 Commits

Author SHA1 Message Date
c552fdc52e Add --dangerously-force-json flag for syncback
Adds a CLI flag that forces syncback to use JSON representations
instead of binary .rbxm files. Instances with children become
directories with init.meta.json; leaf instances become .model.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:41:42 +01:00
Ken Loeffler
02b41133f8 Use post for ref patch and serialize (#1192) 2026-01-19 22:44:42 +00:00
9 changed files with 131 additions and 71 deletions

View File

@@ -31,8 +31,10 @@ Making a new release? Simply add the new header with the version and date undern
## Unreleased ## Unreleased
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179]) * Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179 [#1179]: https://github.com/rojo-rbx/rojo/pull/1179
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
## [7.7.0-rc.1] (November 27th, 2025) ## [7.7.0-rc.1] (November 27th, 2025)

View File

@@ -290,31 +290,39 @@ function ApiContext:open(id)
end end
function ApiContext:serialize(ids: { string }) function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/serialize"):format(self.__baseUrl)
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) return Http.post(url, request_body)
if body.sessionId ~= self.__sessionId then :andThen(rejectFailedRequests)
return Promise.reject("Server changed ID") :andThen(Http.Response.json)
end :andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiSerialize(body)) assert(validateApiSerialize(response_body))
return body return response_body
end) end)
end end
function ApiContext:refPatch(ids: { string }) function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ",")) local url = ("%s/api/ref-patch"):format(self.__baseUrl)
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) return Http.post(url, request_body)
if body.sessionId ~= self.__sessionId then :andThen(rejectFailedRequests)
return Promise.reject("Server changed ID") :andThen(Http.Response.json)
end :andThen(function(response_body)
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiRefPatch(body)) assert(validateApiRefPatch(response_body))
return body return response_body
end) end)
end end
return ApiContext return ApiContext

View File

@@ -54,6 +54,11 @@ pub struct SyncbackCommand {
/// If provided, the prompt for writing to the file system is skipped. /// If provided, the prompt for writing to the file system is skipped.
#[clap(long, short = 'y')] #[clap(long, short = 'y')]
pub non_interactive: bool, pub non_interactive: bool,
/// If provided, forces syncback to use JSON model files instead of binary
/// .rbxm files for instances that would otherwise serialize as binary.
#[clap(long)]
pub dangerously_force_json: bool,
} }
impl SyncbackCommand { impl SyncbackCommand {
@@ -104,6 +109,7 @@ impl SyncbackCommand {
&mut dom_old, &mut dom_old,
dom_new, dom_new,
session_old.root_project(), session_old.root_project(),
self.dangerously_force_json,
)?; )?;
log::debug!( log::debug!(
"Syncback finished in {:.02}s!", "Syncback finished in {:.02}s!",

View File

@@ -52,6 +52,7 @@ pub fn syncback_loop(
old_tree: &mut RojoTree, old_tree: &mut RojoTree,
mut new_tree: WeakDom, mut new_tree: WeakDom,
project: &Project, project: &Project,
force_json: bool,
) -> anyhow::Result<FsSnapshot> { ) -> anyhow::Result<FsSnapshot> {
let ignore_patterns = project let ignore_patterns = project
.syncback_rules .syncback_rules
@@ -153,6 +154,7 @@ pub fn syncback_loop(
old_tree, old_tree,
new_tree: &new_tree, new_tree: &new_tree,
project, project,
force_json,
}; };
let mut snapshots = vec![SyncbackSnapshot { let mut snapshots = vec![SyncbackSnapshot {
@@ -197,7 +199,7 @@ pub fn syncback_loop(
} }
} }
let middleware = get_best_middleware(&snapshot); let middleware = get_best_middleware(&snapshot, force_json);
log::trace!( log::trace!(
"Middleware for {inst_path} is {:?} (path is {})", "Middleware for {inst_path} is {:?} (path is {})",
@@ -213,10 +215,14 @@ pub fn syncback_loop(
let syncback = match middleware.syncback(&snapshot) { let syncback = match middleware.syncback(&snapshot) {
Ok(syncback) => syncback, Ok(syncback) => syncback,
Err(err) if middleware == Middleware::Dir => { Err(err) if middleware == Middleware::Dir => {
let new_middleware = match env::var(DEBUG_MODEL_FORMAT_VAR) { let new_middleware = if force_json {
Ok(value) if value == "1" => Middleware::Rbxmx, Middleware::JsonModel
Ok(value) if value == "2" => Middleware::JsonModel, } else {
_ => Middleware::Rbxm, match env::var(DEBUG_MODEL_FORMAT_VAR) {
Ok(value) if value == "1" => Middleware::Rbxmx,
Ok(value) if value == "2" => Middleware::JsonModel,
_ => Middleware::Rbxm,
}
}; };
let file_name = snapshot let file_name = snapshot
.path .path
@@ -295,7 +301,7 @@ pub struct SyncbackReturn<'sync> {
pub removed_children: Vec<InstanceWithMeta<'sync>>, pub removed_children: Vec<InstanceWithMeta<'sync>>,
} }
pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware { pub fn get_best_middleware(snapshot: &SyncbackSnapshot, force_json: bool) -> Middleware {
// At some point, we're better off using an O(1) method for checking // At some point, we're better off using an O(1) method for checking
// equality for classes like this. // equality for classes like this.
static JSON_MODEL_CLASSES: OnceLock<HashSet<&str>> = OnceLock::new(); static JSON_MODEL_CLASSES: OnceLock<HashSet<&str>> = OnceLock::new();
@@ -361,10 +367,18 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware {
} }
if middleware == Middleware::Rbxm { if middleware == Middleware::Rbxm {
middleware = match env::var(DEBUG_MODEL_FORMAT_VAR) { middleware = if force_json {
Ok(value) if value == "1" => Middleware::Rbxmx, if !inst.children().is_empty() {
Ok(value) if value == "2" => Middleware::JsonModel, Middleware::Dir
_ => Middleware::Rbxm, } else {
Middleware::JsonModel
}
} else {
match env::var(DEBUG_MODEL_FORMAT_VAR) {
Ok(value) if value == "1" => Middleware::Rbxmx,
Ok(value) if value == "2" => Middleware::JsonModel,
_ => Middleware::Rbxm,
}
} }
} }

View File

@@ -20,6 +20,7 @@ pub struct SyncbackData<'sync> {
pub(super) old_tree: &'sync RojoTree, pub(super) old_tree: &'sync RojoTree,
pub(super) new_tree: &'sync WeakDom, pub(super) new_tree: &'sync WeakDom,
pub(super) project: &'sync Project, pub(super) project: &'sync Project,
pub(super) force_json: bool,
} }
pub struct SyncbackSnapshot<'sync> { pub struct SyncbackSnapshot<'sync> {
@@ -43,7 +44,7 @@ impl<'sync> SyncbackSnapshot<'sync> {
path: PathBuf::new(), path: PathBuf::new(),
middleware: None, middleware: None,
}; };
let middleware = get_best_middleware(&snapshot); let middleware = get_best_middleware(&snapshot, self.data.force_json);
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?; let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
snapshot.path = self.path.join(name.as_ref()); snapshot.path = self.path.join(name.as_ref());
@@ -69,7 +70,7 @@ impl<'sync> SyncbackSnapshot<'sync> {
path: PathBuf::new(), path: PathBuf::new(),
middleware: None, middleware: None,
}; };
let middleware = get_best_middleware(&snapshot); let middleware = get_best_middleware(&snapshot, self.data.force_json);
let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?; let name = name_for_inst(middleware, snapshot.new_inst(), snapshot.old_inst())?;
snapshot.path = base_path.join(name.as_ref()); snapshot.path = base_path.join(name.as_ref());

View File

@@ -1,13 +1,7 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return //! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! JSON. //! JSON.
use std::{ use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc};
collections::{HashMap, HashSet},
fs,
path::PathBuf,
str::FromStr,
sync::Arc,
};
use futures::{sink::SinkExt, stream::StreamExt}; use futures::{sink::SinkExt, stream::StreamExt};
use hyper::{body, Body, Method, Request, Response, StatusCode}; use hyper::{body, Body, Method, Request, Response, StatusCode};
@@ -30,7 +24,10 @@ use crate::{
}, },
util::{json, json_ok}, util::{json, json_ok},
}, },
web_api::{BufferEncode, InstanceUpdate, RefPatchResponse, SerializeResponse}, web_api::{
BufferEncode, InstanceUpdate, RefPatchRequest, RefPatchResponse, SerializeRequest,
SerializeResponse,
},
}; };
pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> { pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> {
@@ -53,12 +50,8 @@ pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>)
) )
} }
} }
(&Method::GET, path) if path.starts_with("/api/serialize/") => { (&Method::POST, "/api/serialize") => service.handle_api_serialize(request).await,
service.handle_api_serialize(request).await (&Method::POST, "/api/ref-patch") => service.handle_api_ref_patch(request).await,
}
(&Method::GET, path) if path.starts_with("/api/ref-patch/") => {
service.handle_api_ref_patch(request).await
}
(&Method::POST, path) if path.starts_with("/api/open/") => { (&Method::POST, path) if path.starts_with("/api/open/") => {
service.handle_api_open(request).await service.handle_api_open(request).await
@@ -229,22 +222,30 @@ impl ApiService {
/// that correspond to the requested Instances. These values have their /// that correspond to the requested Instances. These values have their
/// `Value` property set to point to the requested Instance. /// `Value` property set to point to the requested Instance.
async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> { async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/serialize/".len()..]; let session_id = self.serve_session.session_id();
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect(); let body = body::to_bytes(request.into_body()).await.unwrap();
let requested_ids = match requested_ids { let request: SerializeRequest = match json::from_slice(&body) {
Ok(ids) => ids, Ok(request) => request,
Err(_) => { Err(err) => {
return json( return json(
ErrorResponse::bad_request("Malformed ID list"), ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
} }
}; };
if request.session_id != session_id {
return json(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
}
let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder")); let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder"));
let tree = self.serve_session.tree(); let tree = self.serve_session.tree();
for id in &requested_ids { for id in &request.ids {
if let Some(instance) = tree.get_instance(*id) { if let Some(instance) = tree.get_instance(*id) {
let clone = response_dom.insert( let clone = response_dom.insert(
Ref::none(), Ref::none(),
@@ -290,20 +291,26 @@ impl ApiService {
/// and referent properties need to be updated after the serialize /// and referent properties need to be updated after the serialize
/// endpoint is used. /// endpoint is used.
async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> { async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/ref-patch/".len()..]; let session_id = self.serve_session.session_id();
let requested_ids: Result<HashSet<Ref>, _> = let body = body::to_bytes(request.into_body()).await.unwrap();
argument.split(',').map(Ref::from_str).collect();
let requested_ids = match requested_ids { let request: RefPatchRequest = match json::from_slice(&body) {
Ok(ids) => ids, Ok(request) => request,
Err(_) => { Err(err) => {
return json( return json(
ErrorResponse::bad_request("Malformed ID list"), ErrorResponse::bad_request(format!("Invalid body: {}", err)),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
); );
} }
}; };
if request.session_id != session_id {
return json(
ErrorResponse::bad_request("Wrong session ID"),
StatusCode::BAD_REQUEST,
);
}
let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new(); let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new();
let tree = self.serve_session.tree(); let tree = self.serve_session.tree();
@@ -312,7 +319,7 @@ impl ApiService {
let Variant::Ref(prop_value) = prop_value else { let Variant::Ref(prop_value) = prop_value else {
continue; continue;
}; };
if let Some(target_id) = requested_ids.get(prop_value) { if let Some(target_id) = request.ids.get(prop_value) {
let instance_id = instance.id(); let instance_id = instance.id();
let update = let update =
instance_updates instance_updates

View File

@@ -238,6 +238,13 @@ pub struct OpenResponse {
pub session_id: SessionId, pub session_id: SessionId,
} }
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SerializeRequest {
pub session_id: SessionId,
pub ids: Vec<Ref>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SerializeResponse { pub struct SerializeResponse {
@@ -269,6 +276,13 @@ impl BufferEncode {
} }
} }
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefPatchRequest {
pub session_id: SessionId,
pub ids: HashSet<Ref>,
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct RefPatchResponse<'a> { pub struct RefPatchResponse<'a> {

View File

@@ -1,5 +1,4 @@
use std::{ use std::{
fmt::Write as _,
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
@@ -13,8 +12,12 @@ use rbx_dom_weak::types::Ref;
use tempfile::{tempdir, TempDir}; use tempfile::{tempdir, TempDir};
use librojo::web_api::{ use librojo::{
ReadResponse, SerializeResponse, ServerInfoResponse, SocketPacket, SocketPacketType, web_api::{
ReadResponse, SerializeRequest, SerializeResponse, ServerInfoResponse, SocketPacket,
SocketPacketType,
},
SessionId,
}; };
use rojo_insta_ext::RedactionMap; use rojo_insta_ext::RedactionMap;
@@ -226,16 +229,19 @@ impl TestServeSession {
} }
} }
pub fn get_api_serialize(&self, ids: &[Ref]) -> Result<SerializeResponse, reqwest::Error> { pub fn get_api_serialize(
let mut id_list = String::with_capacity(ids.len() * 33); &self,
for id in ids { ids: &[Ref],
write!(id_list, "{id},").unwrap(); session_id: SessionId,
} ) -> Result<SerializeResponse, reqwest::Error> {
id_list.pop(); let client = reqwest::blocking::Client::new();
let url = format!("http://localhost:{}/api/serialize", self.port);
let body = serde_json::to_string(&SerializeRequest {
session_id,
ids: ids.to_vec(),
});
let url = format!("http://localhost:{}/api/serialize/{}", self.port, id_list); client.post(url).body((body).unwrap()).send()?.json()
reqwest::blocking::get(url)?.json()
} }
} }

View File

@@ -646,7 +646,7 @@ fn meshpart_with_id() {
.unwrap(); .unwrap();
let serialize_response = session let serialize_response = session
.get_api_serialize(&[*meshpart, *objectvalue]) .get_api_serialize(&[*meshpart, *objectvalue], info.session_id)
.unwrap(); .unwrap();
// We don't assert a snapshot on the SerializeResponse because the model includes the // We don't assert a snapshot on the SerializeResponse because the model includes the
@@ -673,7 +673,9 @@ fn forced_parent() {
read_response.intern_and_redact(&mut redactions, root_id) read_response.intern_and_redact(&mut redactions, root_id)
); );
let serialize_response = session.get_api_serialize(&[root_id]).unwrap(); let serialize_response = session
.get_api_serialize(&[root_id], info.session_id)
.unwrap();
assert_eq!(serialize_response.session_id, info.session_id); assert_eq!(serialize_response.session_id, info.session_id);