This commit is contained in:
Joe 2026-02-28 06:11:16 -06:00
parent 89e7e0282d
commit 4985a3fede
17 changed files with 891 additions and 63 deletions

View file

@ -0,0 +1,371 @@
---
name: Script index pruning
overview: "Scripts-only mode: add script index to RojoTree for fast server-side pruning, send syncScriptsOnly to plugin so it skips non-script two-way sync work, and add server-side defense filtering on write. Comprehensive test fixtures for all paths."
todos:
- id: script-index
content: "Add `script_refs: HashSet<Ref>` to `RojoTree` and maintain it in `insert_instance()`, `remove()`, `new()`"
status: completed
- id: class-change
content: Handle ClassName changes in `apply_update_child()` with `update_script_tracking()`
status: completed
- id: optimize-read
content: Rewrite `handle_api_read` scripts-only path to use script index + ancestor walk
status: completed
- id: server-info
content: Add `syncScriptsOnly` to ServerInfoResponse and populate from project config
status: completed
- id: ws-inject-ancestors
content: Fix filter_subscribe_message_for_scripts to inject missing ancestor instances when a new script appears in a previously-pruned subtree
status: completed
- id: plugin-filter
content: "Plugin: skip non-script instances in ChangeBatcher when syncScriptsOnly is true"
status: completed
- id: plugin-descendant-added
content: "Plugin: connect DescendantAdded on synced services to detect new scripts created in Studio outside the mapped tree"
status: completed
- id: server-write-filter
content: "Server: filter non-script updates in handle_api_write when scripts-only mode"
status: completed
- id: tests
content: "Add test fixtures: read-API pruning, WebSocket ancestor injection, write-API filtering, new-script-in-pruned-subtree"
status: completed
- id: cleanup
content: Remove now-unused `collect_scripts_and_ancestors` helper, share `is_script_class`
status: completed
isProject: false
---
# Scripts-Only Mode: Full Pruning (Server + Plugin)
## Problem
When `syncScriptsOnly` is enabled:
1. **Server read path**: `handle_api_read` walks the entire descendant tree (O(n)) just to find scripts -- wasteful for large places with 100K+ non-script instances.
2. **Plugin two-way sync**: Plugin has no knowledge of scripts-only mode. It batches and sends property changes for non-script ancestor containers back to the server, which processes them unnecessarily.
3. **Server write path**: `handle_api_write` processes all incoming updates regardless of scripts-only mode -- no defense-in-depth filtering.
## Solution
Three-pronged approach:
- **Server**: Script index on `RojoTree` for O(scripts * depth) reads, filter writes
- **Plugin**: Receive `syncScriptsOnly` flag, skip non-script instances in `ChangeBatcher`
- **Tests**: Fixtures verifying pruning in read, WebSocket, write, and plugin paths
---
## Part 1: Server-Side Script Index
### 1a. Add script index to `RojoTree` ([src/snapshot/tree.rs](src/snapshot/tree.rs))
- Add `script_refs: HashSet<Ref>` field to `RojoTree` (alongside `path_to_ids`, `specified_id_to_refs`)
- Initialize in `RojoTree::new()`
- `**insert_instance()`**: after inserting, if `is_script_class(class_name)` then `self.script_refs.insert(referent)` (recursive children handled by the recursive call)
- `**remove()`**: `self.script_refs.remove(&id)` in the existing descendant cleanup loop
- Add `pub fn script_refs(&self) -> &HashSet<Ref>` accessor
- Add `pub(crate) fn update_script_tracking(&mut self, id: Ref, old_class: &str, new_class: &str)` for ClassName changes
- Add `pub(crate) fn is_script_class(class_name: &str) -> bool` free function (shared by `tree.rs` and `api.rs`)
### 1b. Handle ClassName changes ([src/snapshot/patch_apply.rs](src/snapshot/patch_apply.rs))
In `apply_update_child()` (line 270), when `changed_class_name` is present:
- Capture old class name before the mutable borrow begins
- After mutable borrow is dropped, call `tree.update_script_tracking(id, &old_class, new_class.as_str())`
### 1c. Optimize `handle_api_read` ([src/web/api.rs](src/web/api.rs) lines 3103-3138)
Replace the two-pass full-tree walk with script-index-based ancestor walk:
```rust
let all_scripts = tree.script_refs();
let mut included_ids: HashSet<Ref> = HashSet::new();
let requested_set: HashSet<Ref> = requested_ids.iter().copied().collect();
for &script_id in all_scripts {
let mut chain = Vec::new();
let mut current = script_id;
let mut is_descendant = false;
while let Some(inst) = tree.get_instance(current) {
if included_ids.contains(&current) {
is_descendant = true;
break;
}
if requested_set.contains(&current) {
is_descendant = true;
chain.push(current);
break;
}
chain.push(current);
let parent = inst.parent();
if parent.is_none() { break; }
current = parent;
}
if is_descendant {
included_ids.extend(chain);
}
}
for &id in &included_ids {
if let Some(instance) = tree.get_instance(id) {
instances.insert(id, instance_for_scripts_only(instance, &included_ids));
}
}
```
### 1d. Fix WebSocket ancestor injection ([src/web/api.rs](src/web/api.rs) `filter_subscribe_message_for_scripts`)
**Bug**: When a new script appears on disk inside a previously-pruned subtree, the WebSocket patch contains only the new script in `msg.added`. The function walks up the parent chain and adds ancestors to `included_ids`, but line 3574 only **retains** from `msg.added`. Ancestors that already exist in the tree (but are NOT in the plugin's InstanceMap because they were pruned) are never sent. The plugin receives an orphaned script with an unknown parent.
**Fix**: After building `included_ids`, inject missing ancestors from the tree into `msg.added`:
```rust
// After: msg.added.retain(|id, _| included_ids.contains(id));
// Inject ancestors that the plugin may not have (they exist in the tree
// but weren't part of this patch). Without this, a new script in a
// previously-pruned subtree arrives with an unknown parent.
for &id in &included_ids {
if !msg.added.contains_key(&id) {
if let Some(tree_inst) = tree.get_instance(id) {
let inst = instance_for_scripts_only(tree_inst, &included_ids);
msg.added.insert(id, inst);
}
}
}
```
The Reconciler handles redundant ancestors gracefully (matches them to existing Studio instances via the matching algorithm), so injecting an ancestor the plugin already has is harmless.
### 1e. Filter writes in `handle_api_write` ([src/web/api.rs](src/web/api.rs) line 447)
When `sync_scripts_only()` is true, filter incoming `WriteRequest`:
- `**updated**`: Retain only updates where `tree.get_instance(id)` is a script class
- `**added**`: Allow all (scripts + children of scripts are valid additions)
- `**removed**`: Allow all (plugin should only send script removals, but let server handle gracefully)
Defense-in-depth -- the plugin should already filter, but the server shouldn't blindly trust client data.
---
## Part 2: Server-to-Plugin Communication
### 2a. Add `syncScriptsOnly` to ServerInfoResponse ([src/web/interface.rs](src/web/interface.rs) line 260)
Add new field after `sync_source_only`:
```rust
#[serde(default)]
pub sync_scripts_only: bool,
```
### 2b. Populate from project config ([src/web/api.rs](src/web/api.rs) line 395)
In `handle_api_rojo()`, add:
```rust
sync_scripts_only: self.serve_session.sync_scripts_only(),
```
The accessor already exists at `serve_session.rs` line 501.
---
## Part 3: Plugin-Side Filtering
### 3a. Store flag in ServeSession ([plugin/src/ServeSession.lua](plugin/src/ServeSession.lua))
In `__computeInitialPatch` where `serverInfo` is received (line 619):
```lua
self.__syncScriptsOnly = serverInfo.syncScriptsOnly or false
self.__changeBatcher:setSyncScriptsOnly(self.__syncScriptsOnly)
```
### 3b. Filter in ChangeBatcher ([plugin/src/ChangeBatcher/init.lua](plugin/src/ChangeBatcher/init.lua))
Add `__syncScriptsOnly` field. In `add()` (line 120), early return for non-script instances:
```lua
function ChangeBatcher:add(instance, propertyName)
if self.__syncScriptsOnly and not instance:IsA("LuaSourceContainer") then
return
end
-- ... existing code ...
end
```
Using `IsA("LuaSourceContainer")` is the idiomatic Roblox way to check Script/LocalScript/ModuleScript (it catches all three and any future subclasses).
Add `setSyncScriptsOnly()` method:
```lua
function ChangeBatcher:setSyncScriptsOnly(value)
self.__syncScriptsOnly = value
end
```
### 3c. Detect new scripts created in Studio ([plugin/src/ServeSession.lua](plugin/src/ServeSession.lua))
**Problem**: The plugin only connects `Changed` signals on instances in its InstanceMap. If a user creates a new Script inside a container that was pruned (no scripts), the plugin has zero signals on that container and the new script goes undetected. The plugin doesn't use `DescendantAdded` anywhere today.
**Fix**: When `syncScriptsOnly` is true, connect `DescendantAdded` on each synced service root. When a new `LuaSourceContainer` appears outside the mapped tree, encode it and its unmapped ancestor chain as additions and send via the write API.
**Key insight**: `syncback_added_instance` ([src/web/api.rs](src/web/api.rs) line 1097) already has **upsert logic** -- it checks `find_child_by_name` and updates in place if the instance already exists in the tree (returns `Ok(true)` to skip PatchAdd). This means the plugin can send intermediate containers as "additions" and the server won't create duplicates.
**Plugin implementation** (in `ServeSession.lua`, after initial sync):
```lua
if self.__syncScriptsOnly then
for id, instance in self.__instanceMap.fromIds do
if instance.Parent == game then
local conn = instance.DescendantAdded:Connect(function(descendant)
if not self.__twoWaySync then return end
if not descendant:IsA("LuaSourceContainer") then return end
if self.__instanceMap.fromInstances[descendant] then return end
local chain = {} -- unmapped ancestors, ordered parent-first
local current = descendant.Parent
while current and current ~= game do
if self.__instanceMap.fromInstances[current] then
break
end
table.insert(chain, 1, current)
current = current.Parent
end
local mappedParent = current
local mappedParentId = self.__instanceMap.fromInstances[mappedParent]
if not mappedParentId then return end
local patch = PatchSet.newEmpty()
local prevParentId = mappedParentId
-- Intermediate containers: skeleton only (className, name, parent).
-- No properties -- the server already has these instances on disk.
-- Sending properties would trigger syncback_update_existing_instance
-- which writes to disk, risking round-trip identity violations.
for _, intermediate in chain do
local tempId = HttpService:GenerateGUID()
patch.added[tempId] = {
parent = prevParentId,
name = intermediate.Name,
className = intermediate.ClassName,
properties = {},
children = {},
}
self.__instanceMap:insert(tempId, intermediate)
prevParentId = tempId
end
-- The script itself gets full encoding (Source, etc.)
local scriptTempId = HttpService:GenerateGUID()
patch.added[scriptTempId] = encodeInstance(descendant, prevParentId)
self.__instanceMap:insert(scriptTempId, descendant)
self.__apiContext:write(patch)
end)
table.insert(self.__connections, conn)
end
end
end
```
**Round-trip identity**: Intermediate containers are sent as skeletons (className + name only, empty properties). The server's upsert logic (`syncback_added_instance` line 1097) matches them by name to existing tree instances and returns `Ok(true)` to skip file creation. For non-script types with empty properties, the upsert at line 1370 (`if !added.properties.is_empty()`) skips the meta-file write entirely. This ensures the filesystem is NOT modified for containers that already exist. Only the new script file gets written.
**Server-side handling**: No new server code needed. The existing upsert handles intermediate containers without creating duplicates or modifying existing files. The new script gets created as a new file. The VFS watcher picks up the new file, the tree updates, and subsequent WebSocket patches include the script normally.
**After the write completes**: The server broadcasts a patch that includes the new script. The plugin receives it and the InstanceMap gets updated with the real server-assigned Ref (the temp ID was for the write request only -- the server's applied patch uses its own Refs). The plugin's InstanceMap may briefly have stale temp IDs, but the next patch from the server will replace them via hydration.
**Note**: The server side already handles the reverse direction: when a script file is created on disk in a pruned subtree, the VFS watcher picks it up, adds it to the tree and `script_refs`, and the WebSocket path (with the 1d ancestor injection fix) correctly sends it to the plugin.
---
## Part 4: Test Fixtures
### 4a. New test fixture: `rojo-test/serve-tests/scripts_only_read_pruning/`
Project with scripts at various depths mixed with non-script instances. Tests that `GET /api/read` only returns scripts and their ancestors -- verifies the script index produces correct results.
```
default.project.json5 (syncScriptsOnly: true)
src/
TopScript.server.luau
DeepNest/
Middle/
DeepScript.luau
PureParts/
PartA.model.json5
PartB.model.json5
MixedFolder/
SomeScript.luau
SomeModel.model.json5
```
**Test assertions:**
- Read response includes `TopScript`, `DeepScript`, `SomeScript` and their ancestor containers
- Read response does NOT include `PartA`, `PartB`, `SomeModel`, or the `PureParts` folder
- All included non-script ancestors have `ignoreUnknownInstances: true` and empty properties
- Instance count matches expected (scripts + ancestors only)
### 4b. New tests in `tests/tests/serve.rs` (or `syncback_format_transitions.rs`)
- `**scripts_only_read_prunes_non_script_subtrees`**: Verify read response excludes entire `PureParts/` subtree
- `**scripts_only_read_includes_deep_ancestors`**: Verify deeply nested scripts pull in full ancestor chain
- `**scripts_only_websocket_injects_ancestors_for_new_script`**: Create a new script file on disk inside a previously-pruned subtree (`PureParts/NewScript.luau`), verify the WebSocket message includes both the new script AND the `PureParts` ancestor container
- `**scripts_only_websocket_prunes_non_script_patches`**: Add a non-script file on disk, verify WebSocket message filters it out
- `**scripts_only_write_rejects_non_script_updates`**: POST an update for a non-script instance, verify it's filtered/ignored
- `**scripts_only_serverinfo_flag**`: Verify `GET /api/rojo` includes `syncScriptsOnly: true`
### 4c. Existing fixture enhancement
The existing `rojo-test/serve-tests/syncback_scripts_only/` fixture already has `NonScriptModel.model.json5`. Existing tests focus on syncback write behavior. Add a new test using this fixture that verifies `NonScriptModel` is absent from the read response.
---
## Mode Transitions (Stop Server -> Toggle syncScriptsOnly -> Restart)
This works with zero plugin-side mode-switching logic. The transitions are inherently safe because:
1. `App:endSession()` calls `serveSession:stop()` -> `instanceMap:stop()` which **removes ALL mappings**
2. `App:startSession()` creates a **brand new** `ServeSession` with a fresh `InstanceMap` and `ChangeBatcher`
3. The fresh session calls `start()` -> `GET /api/rojo` (receives `syncScriptsOnly` flag) -> `GET /api/read` (receives pruned or full tree depending on mode) -> `hydrate()` + `diff()`
**Normal -> Scripts-only**: Server returns pruned tree. Fresh InstanceMap gets only scripts + ancestors. Studio's non-script instances are untouched (all nodes have `ignoreUnknownInstances: true`). ChangeBatcher receives `syncScriptsOnly = true` and filters non-script changes.
**Scripts-only -> Normal**: Server returns full tree. Fresh InstanceMap gets everything. `hydrate()` matches server instances to existing Studio instances by Name+ClassName (Studio already has them -- they were never deleted). ChangeBatcher receives `syncScriptsOnly = false` and sends all changes.
No special handling needed. The plugin doesn't need to know it was previously in a different mode.
## What stays the same
- `**instance_for_scripts_only`**: Still used to build filtered Instance objects. No logic change.
- `**InstanceMap.__connectSignals()`**: Signals still connected for all mapped instances. The ChangeBatcher filtering (3b) is upstream and simpler than skipping signal connections, which could miss edge cases with Parent change detection.
## atlas.mdc Conformance
**Round-trip identity**: Preserved. Scripts-only pruning only affects what is SENT over the wire, not what is stored on disk. The filesystem always contains the full tree. Building an rbxl from the filesystem produces the same result regardless of scripts-only mode. The skeleton encoding for intermediate containers (step 3c) ensures no filesystem writes for existing non-script instances.
**Code quality / DRY**: `is_script_class()` is extracted as a shared `pub(crate)` free function in `tree.rs`, replacing the private duplicate in `api.rs`. The `collect_scripts_and_ancestors` helper is removed (replaced by the script index). No new duplication introduced.
**Two-way sync parity with CLI syncback**: In scripts-only mode, two-way sync only handles script instances. This is a different operating mode, not a divergence from syncback behavior. When `syncScriptsOnly` is false, all paths are identical to before.
**Error handling**: All new Rust code uses `anyhow::Context` and `?` propagation per atlas.mdc conventions. No `unwrap()` or `expect()` in library code. Plugin code guards against nil InstanceMap lookups and destroyed instances.
**Protocol compatibility**: The new `syncScriptsOnly` field on `ServerInfoResponse` uses `#[serde(default)]`, making it backward-compatible. Old plugins ignore the field (defaults to `false`). No protocol version bump needed.
**No unnecessary changes**: The plan does not modify globIgnorePaths handling (already correct at the snapshot layer), does not rename variables, and does not refactor unrelated code.
## Complexity comparison
- **Initial read** (100K instances, 200 scripts, depth 20): ~200K iterations (before) vs ~4,000 (after)
- **Two-way sync** (plugin): Non-script changes silently dropped in `ChangeBatcher.add()` instead of round-tripping to server
- **Server writes**: Non-script updates filtered before filesystem I/O
- **Tree mutation overhead**: O(1) per insert/remove (HashSet add/remove)
- **Memory**: O(scripts) HashSet entries (negligible)

View file

@ -350,3 +350,8 @@ when in scripts only mode, try to avoid the entire tree where scripts dont exist
[yielding after sync] [yielding after sync]
there is some process that is making disconnecting after a one-shot sync completes take FOREVER if the server is yielding (usually from the reconciler) there is some process that is making disconnecting after a one-shot sync completes take FOREVER if the server is yielding (usually from the reconciler)
- once the one-shot sync completes, it should not be waiting for additional things to come from the server. it just needs to kill the connection immediately. and go back to the main page.
[init scripts]
scripts actually named init or probably even Init cause the sync engine to completely break. it will syncback, but then i cannot even serve the project.
this needs to be slugified when the name exactly matches init, ignoring case [for anything, not just scripts!]

View file

@ -31,6 +31,7 @@ function ChangeBatcher.new(instanceMap, onChangesFlushed)
__onChangesFlushed = onChangesFlushed, __onChangesFlushed = onChangesFlushed,
__pendingPropertyChanges = {}, __pendingPropertyChanges = {},
__syncSourceOnly = false, __syncSourceOnly = false,
__syncScriptsOnly = false,
__paused = false, __paused = false,
-- Map of target Instance -> list of {sourceInstance, propertyName} -- Map of target Instance -> list of {sourceInstance, propertyName}
@ -54,6 +55,10 @@ function ChangeBatcher:setSyncSourceOnly(enabled)
self.__syncSourceOnly = enabled self.__syncSourceOnly = enabled
end end
function ChangeBatcher:setSyncScriptsOnly(enabled)
self.__syncScriptsOnly = enabled
end
-- Pause the batcher to prevent change accumulation during confirmation -- Pause the batcher to prevent change accumulation during confirmation
function ChangeBatcher:pause() function ChangeBatcher:pause()
self.__paused = true self.__paused = true
@ -118,6 +123,10 @@ function ChangeBatcher:__onTargetInstanceInserted(instance)
end end
function ChangeBatcher:add(instance, propertyName) function ChangeBatcher:add(instance, propertyName)
if self.__syncScriptsOnly and not instance:IsA("LuaSourceContainer") then
return
end
local properties = self.__pendingPropertyChanges[instance] local properties = self.__pendingPropertyChanges[instance]
if not properties then if not properties then

View file

@ -113,6 +113,7 @@ function ServeSession.new(options)
__apiContext = options.apiContext, __apiContext = options.apiContext,
__twoWaySync = options.twoWaySync, __twoWaySync = options.twoWaySync,
__syncSourceOnly = false, __syncSourceOnly = false,
__syncScriptsOnly = false,
__reconciler = reconciler, __reconciler = reconciler,
__instanceMap = instanceMap, __instanceMap = instanceMap,
__changeBatcher = changeBatcher, __changeBatcher = changeBatcher,
@ -331,6 +332,9 @@ function ServeSession:start()
self.__syncSourceOnly = serverInfo.syncSourceOnly or false self.__syncSourceOnly = serverInfo.syncSourceOnly or false
self.__changeBatcher:setSyncSourceOnly(self.__syncSourceOnly) self.__changeBatcher:setSyncSourceOnly(self.__syncSourceOnly)
self.__syncScriptsOnly = serverInfo.syncScriptsOnly or false
self.__changeBatcher:setSyncScriptsOnly(self.__syncScriptsOnly)
self.__serverInfo = serverInfo self.__serverInfo = serverInfo
return self:__computeInitialPatch(serverInfo):andThen(function(catchUpPatch) return self:__computeInitialPatch(serverInfo):andThen(function(catchUpPatch)
self:setLoadingText("Starting sync loop...") self:setLoadingText("Starting sync loop...")
@ -360,6 +364,8 @@ function ServeSession:start()
if self.__initialSyncCompleteCallback ~= nil then if self.__initialSyncCompleteCallback ~= nil then
self.__initialSyncCompleteCallback() self.__initialSyncCompleteCallback()
end end
self:__connectScriptsOnlyWatchers()
end) end)
:catch(function(err) :catch(function(err)
if self.__status ~= Status.Disconnected then if self.__status ~= Status.Disconnected then
@ -892,6 +898,82 @@ function ServeSession:__confirmAndApplyInitialPatch(catchUpPatch, serverInfo)
end end
end end
function ServeSession:__connectScriptsOnlyWatchers()
if not self.__syncScriptsOnly then
return
end
if not self.__twoWaySync then
return
end
for _, instance in self.__instanceMap.fromIds do
if instance.Parent ~= game then
continue
end
local conn = instance.DescendantAdded:Connect(function(descendant)
if not self.__twoWaySync then
return
end
if self.__status ~= Status.Connected then
return
end
if not descendant:IsA("LuaSourceContainer") then
return
end
if self.__instanceMap.fromInstances[descendant] then
return
end
local chain = {}
local current = descendant.Parent
while current and current ~= game do
if self.__instanceMap.fromInstances[current] then
break
end
table.insert(chain, 1, current)
current = current.Parent
end
local mappedParent = current
if not mappedParent then
return
end
local mappedParentId = self.__instanceMap.fromInstances[mappedParent]
if not mappedParentId then
return
end
local patch = PatchSet.newEmpty()
local prevParentId = mappedParentId
for _, intermediate in chain do
local tempId = string.gsub(HttpService:GenerateGUID(false), "-", ""):lower()
patch.added[tempId] = {
parent = prevParentId,
name = intermediate.Name,
className = intermediate.ClassName,
properties = {},
children = {},
}
self.__instanceMap:insert(tempId, intermediate)
prevParentId = tempId
end
local scriptTempId = string.gsub(HttpService:GenerateGUID(false), "-", ""):lower()
patch.added[scriptTempId] = encodeInstance(descendant, prevParentId)
self.__instanceMap:insert(scriptTempId, descendant)
Log.info(
"Scripts-only: detected new script {} outside mapped tree, sending to server",
descendant:GetFullName()
)
self.__apiContext:write(patch)
end)
table.insert(self.__connections, conn)
end
end
function ServeSession:__stopInternal(err) function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err) self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect() self.__apiContext:disconnect()

View file

@ -0,0 +1,11 @@
{
"name": "scripts_only_read_pruning",
"syncScriptsOnly": true,
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$className": "ServerScriptService",
"$path": "src"
}
}
}

View file

@ -0,0 +1 @@
return "deep script"

View file

@ -0,0 +1,4 @@
{
"className": "Configuration",
"properties": {}
}

View file

@ -0,0 +1 @@
return "mixed folder script"

View file

@ -0,0 +1,4 @@
{
"className": "Part",
"properties": {}
}

View file

@ -0,0 +1,4 @@
{
"className": "Part",
"properties": {}
}

View file

@ -0,0 +1 @@
print("top level script")

View file

@ -251,6 +251,13 @@ fn apply_update_child(context: &mut PatchApplyContext, tree: &mut RojoTree, patc
applied_patch.changed_metadata = Some(metadata); applied_patch.changed_metadata = Some(metadata);
} }
// Capture old class name before the mutable borrow so we can update
// the script index afterward if the class changes.
let old_class_name = patch.changed_class_name.and_then(|_| {
tree.get_instance(patch.id)
.map(|inst| inst.class_name().to_string())
});
let mut instance = match tree.get_instance_mut(patch.id) { let mut instance = match tree.get_instance_mut(patch.id) {
Some(instance) => instance, Some(instance) => instance,
None => { None => {
@ -307,6 +314,11 @@ fn apply_update_child(context: &mut PatchApplyContext, tree: &mut RojoTree, patc
applied_patch.changed_properties.insert(key, property_entry); applied_patch.changed_properties.insert(key, property_entry);
} }
if let (Some(old_class), Some(new_class)) = (&old_class_name, &applied_patch.changed_class_name)
{
tree.update_script_tracking(patch.id, old_class, new_class.as_str());
}
defer_ref_properties(tree, patch.id, context); defer_ref_properties(tree, patch.id, context);
context.applied_patch_set.updated.push(applied_patch) context.applied_patch_set.updated.push(applied_patch)

View file

@ -1,5 +1,5 @@
use std::{ use std::{
collections::{HashMap, VecDeque}, collections::{HashMap, HashSet, VecDeque},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
@ -12,6 +12,11 @@ use crate::{multimap::MultiMap, RojoRef};
use super::{InstanceMetadata, InstanceSnapshot, InstigatingSource}; use super::{InstanceMetadata, InstanceSnapshot, InstigatingSource};
#[inline]
pub(crate) fn is_script_class(class_name: &str) -> bool {
matches!(class_name, "Script" | "LocalScript" | "ModuleScript")
}
/// An expanded variant of rbx_dom_weak's `WeakDom` that tracks additional /// An expanded variant of rbx_dom_weak's `WeakDom` that tracks additional
/// metadata per instance that's Rojo-specific. /// metadata per instance that's Rojo-specific.
/// ///
@ -39,6 +44,11 @@ pub struct RojoTree {
/// the same RojoRef for multiple different instances. An entry containing /// the same RojoRef for multiple different instances. An entry containing
/// multiple elements is an error condition that should be raised to the user. /// multiple elements is an error condition that should be raised to the user.
specified_id_to_refs: MultiMap<RojoRef, Ref>, specified_id_to_refs: MultiMap<RojoRef, Ref>,
/// Refs of all Script, LocalScript, and ModuleScript instances in the tree.
/// Maintained incrementally for fast scripts-only mode filtering in the
/// serve API without walking the entire tree.
script_refs: HashSet<Ref>,
} }
impl RojoTree { impl RojoTree {
@ -52,6 +62,7 @@ impl RojoTree {
metadata_map: HashMap::new(), metadata_map: HashMap::new(),
path_to_ids: MultiMap::new(), path_to_ids: MultiMap::new(),
specified_id_to_refs: MultiMap::new(), specified_id_to_refs: MultiMap::new(),
script_refs: HashSet::new(),
}; };
let root_ref = tree.inner.root_ref(); let root_ref = tree.inner.root_ref();
@ -124,6 +135,8 @@ impl RojoTree {
_ => Vec::new(), _ => Vec::new(),
}; };
let is_script = is_script_class(snapshot.class_name.as_ref());
let builder = InstanceBuilder::empty() let builder = InstanceBuilder::empty()
.with_class(snapshot.class_name) .with_class(snapshot.class_name)
.with_name(snapshot.name.into_owned()) .with_name(snapshot.name.into_owned())
@ -133,6 +146,10 @@ impl RojoTree {
let referent = self.inner.insert(parent_ref, builder); let referent = self.inner.insert(parent_ref, builder);
self.insert_metadata(referent, snapshot.metadata); self.insert_metadata(referent, snapshot.metadata);
if is_script {
self.script_refs.insert(referent);
}
for child in snapshot.children { for child in snapshot.children {
self.insert_instance(referent, child); self.insert_instance(referent, child);
} }
@ -146,6 +163,7 @@ impl RojoTree {
while let Some(id) = to_move.pop_front() { while let Some(id) = to_move.pop_front() {
self.remove_metadata(id); self.remove_metadata(id);
self.script_refs.remove(&id);
if let Some(instance) = self.inner.get_by_ref(id) { if let Some(instance) = self.inner.get_by_ref(id) {
to_move.extend(instance.children().iter().copied()); to_move.extend(instance.children().iter().copied());
@ -231,6 +249,20 @@ impl RojoTree {
self.specified_id_to_refs.insert(specified, id); self.specified_id_to_refs.insert(specified, id);
} }
pub fn script_refs(&self) -> &HashSet<Ref> {
&self.script_refs
}
pub fn update_script_tracking(&mut self, id: Ref, old_class: &str, new_class: &str) {
let was_script = is_script_class(old_class);
let is_script = is_script_class(new_class);
if was_script && !is_script {
self.script_refs.remove(&id);
} else if !was_script && is_script {
self.script_refs.insert(id);
}
}
/// Looks up an instance by its filesystem-name path in the tree. /// Looks up an instance by its filesystem-name path in the tree.
/// ///
/// Each segment is matched against the **filesystem name** of child /// Each segment is matched against the **filesystem name** of child

View file

@ -393,6 +393,7 @@ impl ApiService {
game_id: self.serve_session.game_id(), game_id: self.serve_session.game_id(),
root_instance_id, root_instance_id,
sync_source_only: true, sync_source_only: true,
sync_scripts_only: self.serve_session.sync_scripts_only(),
ignore_hidden_services, ignore_hidden_services,
visible_services, visible_services,
git_metadata, git_metadata,
@ -450,7 +451,7 @@ impl ApiService {
let body = request.into_body().collect().await.unwrap().to_bytes(); let body = request.into_body().collect().await.unwrap().to_bytes();
let request: WriteRequest = match deserialize_msgpack(&body) { let mut request: WriteRequest = match deserialize_msgpack(&body) {
Ok(request) => request, Ok(request) => request,
Err(err) => { Err(err) => {
return msgpack( return msgpack(
@ -467,6 +468,24 @@ impl ApiService {
); );
} }
if self.serve_session.sync_scripts_only() {
let tree = self.serve_session.tree();
let before = request.updated.len();
request.updated.retain(|update| {
tree.get_instance(update.id)
.map(|inst| {
crate::snapshot::is_script_class(inst.class_name().as_str())
})
.unwrap_or(false)
});
let filtered = before - request.updated.len();
if filtered > 0 {
log::debug!(
"Scripts-only mode: filtered {filtered} non-script update(s) from write request"
);
}
}
// Process removed instances (syncback: delete files from Rojo filesystem) // Process removed instances (syncback: delete files from Rojo filesystem)
// Phase 1: Gather paths with the tree lock held. // Phase 1: Gather paths with the tree lock held.
// Phase 2: Delete files without the lock. // Phase 2: Delete files without the lock.
@ -3101,39 +3120,43 @@ impl ApiService {
let mut instances = HashMap::new(); let mut instances = HashMap::new();
if scripts_only { if scripts_only {
// In scripts-only mode, we need to: // Use the script index to avoid walking the entire tree.
// 1. Find all scripts // For each script, walk UP to see if it's a descendant of a
// 2. Include their ancestor chains (so the tree structure is valid) // requested ID, collecting the ancestor chain along the way.
// 3. Mark non-script ancestors with ignoreUnknownInstances: true
// 4. Filter children to only include instances in our set
let mut included_ids: HashSet<Ref> = HashSet::new(); let mut included_ids: HashSet<Ref> = HashSet::new();
let requested_set: HashSet<Ref> = requested_ids.iter().copied().collect();
// First pass: collect all script IDs and their ancestors for &script_id in tree.script_refs() {
for id in &requested_ids { let mut chain = Vec::new();
if let Some(instance) = tree.get_instance(*id) { let mut current = script_id;
collect_scripts_and_ancestors(&tree, instance.id(), &mut included_ids); let mut is_descendant = false;
for descendant in tree.descendants(*id) {
collect_scripts_and_ancestors(&tree, descendant.id(), &mut included_ids); while let Some(inst) = tree.get_instance(current) {
if included_ids.contains(&current) {
is_descendant = true;
break;
} }
if requested_set.contains(&current) {
is_descendant = true;
chain.push(current);
break;
}
chain.push(current);
let parent = inst.parent();
if parent.is_none() {
break;
}
current = parent;
}
if is_descendant {
included_ids.extend(chain);
} }
} }
// Second pass: create Instance objects for included IDs for &id in &included_ids {
for id in &requested_ids { if let Some(instance) = tree.get_instance(id) {
if let Some(instance) = tree.get_instance(*id) { instances.insert(id, instance_for_scripts_only(instance, &included_ids));
if included_ids.contains(id) {
let inst = instance_for_scripts_only(instance, &included_ids);
instances.insert(*id, inst);
}
for descendant in tree.descendants(*id) {
let desc_id = descendant.id();
if included_ids.contains(&desc_id) {
let inst = instance_for_scripts_only(descendant, &included_ids);
instances.insert(desc_id, inst);
}
}
} }
} }
} else { } else {
@ -3532,17 +3555,13 @@ fn parent_requirements(class: &str) -> Option<&str> {
}) })
} }
/// Checks if a class name is a script type (Script, LocalScript, ModuleScript). use crate::snapshot::is_script_class;
#[inline]
fn is_script_class(class_name: &str) -> bool {
matches!(class_name, "Script" | "LocalScript" | "ModuleScript")
}
/// Filters a SubscribeMessage to only include scripts and their ancestors. /// Filters a SubscribeMessage to only include scripts and their ancestors.
/// Non-script ancestors get ignoreUnknownInstances: true. /// Non-script ancestors get ignoreUnknownInstances: true.
fn filter_subscribe_message_for_scripts( fn filter_subscribe_message_for_scripts<'a>(
tree: &crate::snapshot::RojoTree, tree: &'a crate::snapshot::RojoTree,
msg: &mut SubscribeMessage<'_>, msg: &mut SubscribeMessage<'a>,
) { ) {
use std::borrow::Cow; use std::borrow::Cow;
@ -3574,9 +3593,19 @@ fn filter_subscribe_message_for_scripts(
// Filter added to only include IDs in our set // Filter added to only include IDs in our set
msg.added.retain(|id, _| included_ids.contains(id)); msg.added.retain(|id, _| included_ids.contains(id));
// ALL instances get ignoreUnknownInstances: true in scripts-only mode // Inject ancestors that the plugin may not have yet. When a new script
// This ensures we only sync script content, never delete/modify other instances // appears in a previously-pruned subtree, its ancestor containers exist
// Non-scripts also have their properties cleared - they're just tree structure // in the tree but weren't part of this patch. Without injection the
// plugin receives an orphaned script with an unknown parent.
for id in included_ids.iter().copied().collect::<Vec<_>>() {
if !msg.added.contains_key(&id) {
if let Some(tree_inst) = tree.get_instance(id) {
let inst = instance_for_scripts_only(tree_inst, &included_ids);
msg.added.insert(id, inst);
}
}
}
for inst in msg.added.values_mut() { for inst in msg.added.values_mut() {
inst.metadata = Some(InstanceMetadata { inst.metadata = Some(InstanceMetadata {
ignore_unknown_instances: true, ignore_unknown_instances: true,
@ -3612,29 +3641,6 @@ fn filter_subscribe_message_for_scripts(
}); });
} }
/// Recursively collects a script instance and all its ancestors into the set.
/// Only adds to the set if the instance is a script (then adds ancestors too).
fn collect_scripts_and_ancestors(
tree: &crate::snapshot::RojoTree,
id: Ref,
included_ids: &mut HashSet<Ref>,
) {
if let Some(instance) = tree.get_instance(id) {
if is_script_class(instance.class_name().as_str()) {
// This is a script - add it and all ancestors
let mut current_id = id;
while let Some(inst) = tree.get_instance(current_id) {
included_ids.insert(current_id);
let parent_id = inst.parent();
if parent_id.is_none() || included_ids.contains(&parent_id) {
break;
}
current_id = parent_id;
}
}
}
}
/// Creates an Instance for scripts-only mode. /// Creates an Instance for scripts-only mode.
/// - Scripts get their properties synced normally /// - Scripts get their properties synced normally
/// - Non-scripts only provide tree structure (no properties synced) /// - Non-scripts only provide tree structure (no properties synced)

View file

@ -258,6 +258,10 @@ pub struct ServerInfoResponse {
/// Other property changes will be ignored. /// Other property changes will be ignored.
#[serde(default)] #[serde(default)]
pub sync_source_only: bool, pub sync_source_only: bool,
/// When true, only script instances (and their ancestors for tree structure)
/// are synced. The plugin should skip non-script changes in two-way sync.
#[serde(default)]
pub sync_scripts_only: bool,
/// When true, hidden/internal services should be ignored during sync. /// When true, hidden/internal services should be ignored during sync.
/// The plugin should not mark these services as "to delete" during forward sync. /// The plugin should not mark these services as "to delete" during forward sync.
#[serde(default)] #[serde(default)]

View file

@ -5,6 +5,7 @@ mod connected_mode;
mod git_sync_defaults; mod git_sync_defaults;
mod live_syncback; mod live_syncback;
mod matching_fixtures; mod matching_fixtures;
mod scripts_only_pruning;
mod serve; mod serve;
mod syncback; mod syncback;
mod syncback_format_transitions; mod syncback_format_transitions;

View file

@ -0,0 +1,280 @@
use std::collections::HashMap;
use librojo::web_api::{InstanceUpdate, WriteRequest};
use crate::rojo_test::serve_util::run_serve_test;
#[test]
fn scripts_only_read_prunes_non_script_subtrees() {
run_serve_test("scripts_only_read_pruning", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
let read_response = session.get_api_read(root_id).unwrap();
let names: Vec<&str> = read_response
.instances
.values()
.map(|inst| inst.name.as_ref())
.collect();
assert!(
names.contains(&"TopScript"),
"TopScript should be included"
);
assert!(
names.contains(&"DeepScript"),
"DeepScript should be included"
);
assert!(
names.contains(&"SomeScript"),
"SomeScript should be included"
);
assert!(
!names.contains(&"PartA"),
"PartA should be pruned"
);
assert!(
!names.contains(&"PartB"),
"PartB should be pruned"
);
assert!(
!names.contains(&"SomeModel"),
"SomeModel should be pruned"
);
assert!(
!names.contains(&"PureParts"),
"PureParts folder should be pruned (no script descendants)"
);
});
}
#[test]
fn scripts_only_read_includes_deep_ancestors() {
run_serve_test("scripts_only_read_pruning", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
let read_response = session.get_api_read(root_id).unwrap();
let names: Vec<&str> = read_response
.instances
.values()
.map(|inst| inst.name.as_ref())
.collect();
assert!(
names.contains(&"DeepNest"),
"DeepNest ancestor should be included for DeepScript"
);
assert!(
names.contains(&"Middle"),
"Middle ancestor should be included for DeepScript"
);
assert!(
names.contains(&"DeepScript"),
"DeepScript should be included"
);
});
}
#[test]
fn scripts_only_read_ancestors_have_no_properties() {
run_serve_test("scripts_only_read_pruning", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
let read_response = session.get_api_read(root_id).unwrap();
for (_, inst) in &read_response.instances {
let is_script = matches!(
inst.class_name.as_str(),
"Script" | "LocalScript" | "ModuleScript"
);
if !is_script && inst.name != "scripts_only_read_pruning" {
assert!(
inst.properties.is_empty(),
"Non-script ancestor '{}' ({}) should have empty properties, got {} properties",
inst.name,
inst.class_name,
inst.properties.len()
);
if let Some(ref meta) = inst.metadata {
assert!(
meta.ignore_unknown_instances,
"Non-script ancestor '{}' should have ignoreUnknownInstances: true",
inst.name
);
}
}
}
});
}
#[test]
fn scripts_only_serverinfo_flag() {
run_serve_test("scripts_only_read_pruning", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
assert!(
info.sync_scripts_only,
"syncScriptsOnly should be true in ServerInfoResponse"
);
});
}
#[test]
fn scripts_only_serverinfo_flag_absent_when_disabled() {
run_serve_test("syncback_scripts_only", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
assert!(
info.sync_scripts_only,
"syncScriptsOnly should be true for syncback_scripts_only fixture"
);
});
}
#[test]
fn scripts_only_write_filters_non_script_updates() {
run_serve_test("scripts_only_read_pruning", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
let read_response = session.get_api_read(root_id).unwrap();
let sss_id = read_response
.instances
.iter()
.find(|(_, inst)| inst.class_name == "ServerScriptService")
.map(|(id, _)| *id)
.expect("ServerScriptService should exist");
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: HashMap::new(),
updated: vec![InstanceUpdate {
id: sss_id,
changed_name: Some("Renamed".to_string()),
changed_class_name: None,
changed_properties: Default::default(),
changed_metadata: None,
}],
stage_ids: Vec::new(),
};
session
.post_api_write(&write_request)
.expect("Write request should succeed");
std::thread::sleep(std::time::Duration::from_millis(200));
let read_after = session.get_api_read(root_id).unwrap();
let sss_after = read_after
.instances
.iter()
.find(|(_, inst)| inst.class_name == "ServerScriptService")
.map(|(_, inst)| inst.name.as_ref())
.expect("ServerScriptService should still exist");
assert_eq!(
sss_after, "ServerScriptService",
"Non-script update should have been filtered; name should remain unchanged"
);
});
}
#[test]
fn scripts_only_existing_fixture_prunes_non_script_model() {
run_serve_test("syncback_scripts_only", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
let read_response = session.get_api_read(root_id).unwrap();
let names: Vec<&str> = read_response
.instances
.values()
.map(|inst| inst.name.as_ref())
.collect();
assert!(
!names.contains(&"NonScriptModel"),
"NonScriptModel (Configuration) should be pruned in scripts-only mode"
);
});
}
#[test]
fn scripts_only_websocket_prunes_non_script_additions() {
use librojo::web_api::SocketPacketType;
run_serve_test("scripts_only_read_pruning", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let read_response = session.get_api_read(info.root_instance_id).unwrap();
let cursor = read_response.message_cursor;
let packet = session
.recv_socket_packet(SocketPacketType::Messages, cursor, || {
let pure_parts_dir = session.path().join("src").join("PureParts");
std::fs::write(
pure_parts_dir.join("AnotherPart.model.json5"),
"{ \"className\": \"Part\", \"properties\": {} }\n",
)
.expect("Should write non-script file");
})
.ok();
if let Some(packet) = packet {
let librojo::web_api::SocketPacketBody::Messages(messages_packet) = packet.body;
for msg in &messages_packet.messages {
let has_part = msg
.added
.values()
.any(|inst| inst.name.as_ref() == "AnotherPart");
assert!(
!has_part,
"WebSocket should not include non-script AnotherPart in scripts-only mode"
);
}
}
});
}
#[test]
fn scripts_only_websocket_injects_ancestors_for_new_script() {
use librojo::web_api::SocketPacketType;
run_serve_test("scripts_only_read_pruning", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let read_response = session.get_api_read(info.root_instance_id).unwrap();
let cursor = read_response.message_cursor;
let packet = session
.recv_socket_packet(SocketPacketType::Messages, cursor, || {
let pure_parts_dir = session.path().join("src").join("PureParts");
std::fs::write(
pure_parts_dir.join("NewScript.luau"),
"return \"new script in pruned subtree\"\n",
)
.expect("Should write new script file");
})
.expect("Should receive WebSocket packet for new script");
let librojo::web_api::SocketPacketBody::Messages(messages_packet) = packet.body;
let all_added: Vec<&str> = messages_packet
.messages
.iter()
.flat_map(|msg| msg.added.values())
.map(|inst| inst.name.as_ref())
.collect();
assert!(
all_added.contains(&"NewScript"),
"WebSocket should include the new script. Got: {:?}",
all_added
);
assert!(
all_added.contains(&"PureParts"),
"WebSocket should inject the PureParts ancestor. Got: {:?}",
all_added
);
});
}