goldsync 0.1.31 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -82,6 +82,16 @@ When changing a Folder to another class, Push the changed parent. GoldSync verif
82
82
 
83
83
  ## Daily use
84
84
 
85
+ To make local files authoritative and replace the configured Studio roots completely, run:
86
+
87
+ ```powershell
88
+ npx --yes goldsync@latest restore-studio --yes
89
+ ```
90
+
91
+ Update GoldSync, restart Studio, and connect Rojo first. This command discards unsaved Studio changes inside the configured roots, removes extra instances, and resets their sync state. Other Studio roots and local files are unchanged. It validates every snapshot before replacing the roots and reports completion in the terminal. It requires version 2 tree roots.
92
+
93
+ The command uses the files currently on disk. It does not fetch Git or remove old local `.rbxm` files. Restore the local assets from your intended Git revision first if that is the copy you want in Studio.
94
+
85
95
  Matching assets synchronize automatically. When an entry needs attention, choose the copy you want to keep:
86
96
 
87
97
  | Control | Direction |
@@ -20,15 +20,17 @@ local SESSION_MARKER_NAME = "__GoldSyncSession"
20
20
  local CLIENT_HEADERS = {
21
21
  ["X-GoldSync-Client"] = "studio-plugin",
22
22
  ["X-GoldSync-Tree-Roots"] = "4",
23
+ ["X-GoldSync-Restore"] = "1",
23
24
  }
24
25
  local SETTINGS_PREFIX = "GoldSync:v2:"
25
26
  local UI_SETTINGS_PREFIX = "GoldSync:ui:v1:"
26
- local VERSION = "0.1.31"
27
+ local VERSION = "0.1.34"
27
28
  local startupProfile = { settings = 0, watchers = 0, reconcile = 0, downloads = 0 }
28
29
  local savedHashes = {}
29
30
  local savedHashesKey
30
31
  local hashesDirty = false
31
32
  local TreeRoots = require(script.Parent.TreeRoots)
33
+ local StudioRestore = require(script.Parent.StudioRestore)
32
34
  local REQUEST_INTERVAL_SECONDS = 0.15
33
35
  local REQUEST_RETRY_DELAYS = { 1, 2, 4 }
34
36
 
@@ -393,6 +395,7 @@ rootsLayout.Parent = rootsContainer
393
395
 
394
396
  local running = true
395
397
  local bulkRunning = false
398
+ local refreshing = false
396
399
  local requestLocked = false
397
400
  local requestWaiters = {}
398
401
  local lastRequestAt = 0
@@ -420,27 +423,41 @@ local resolveGitConflict
420
423
  local applyTreeFilter
421
424
  local resetInstanceTrees
422
425
 
426
+ local function pullStates(affected)
427
+ table.sort(affected, function(left, right)
428
+ local leftDepth, rightDepth = #left.manifest.studioPath, #right.manifest.studioPath
429
+ if leftDepth ~= rightDepth then return leftDepth < rightDepth end
430
+ return left.manifest.id < right.manifest.id
431
+ end)
432
+ for _, state in affected do
433
+ if states[state.manifest.id] == state and not state.manifest.gitConflict then
434
+ pull(state)
435
+ end
436
+ end
437
+ end
438
+
423
439
  for index, button in attentionBulkButtons do
424
440
  button.Activated:Connect(function()
425
441
  if bulkRunning or not serverConfig or not serverConfig.rojoConnected then
426
442
  return
427
443
  end
444
+ bulkRunning = true
445
+ while refreshing do task.wait() end
428
446
  local affected = {}
429
447
  for _, state in states do
430
448
  if state.statusKind == "issue" and not state.manifest.gitConflict then
431
449
  table.insert(affected, state)
432
450
  end
433
451
  end
434
- bulkRunning = true
435
452
  refreshStatusSummary()
436
- for _, state in affected do
437
- if states[state.manifest.id] ~= state or state.manifest.gitConflict then
438
- continue
439
- end
440
- if index == 1 then
453
+ if index == 2 then
454
+ pullStates(affected)
455
+ else
456
+ for _, state in affected do
457
+ if states[state.manifest.id] ~= state or state.manifest.gitConflict then
458
+ continue
459
+ end
441
460
  upload(state, true)
442
- else
443
- pull(state)
444
461
  end
445
462
  end
446
463
  bulkRunning = false
@@ -1324,7 +1341,7 @@ deleteSnapshot = function(state, explicit)
1324
1341
  end
1325
1342
 
1326
1343
  upload = function(state, overwrite)
1327
- if state.coveredByBoundary then return end
1344
+ if state.coveredByBoundary or state.suppress then return end
1328
1345
  if state.uploading or state.pulling or state.deleting then
1329
1346
  return
1330
1347
  end
@@ -1422,6 +1439,7 @@ upload = function(state, overwrite)
1422
1439
  setStatus(state, "Conversion failed: " .. tostring(packet), Color3.fromRGB(245, 106, 106))
1423
1440
  return
1424
1441
  end
1442
+ if state.suppress then state.uploading = false return end
1425
1443
  local response, failure = request("PUT", "/v1/roots/" .. state.manifest.id .. "/boundary", packet, { ["Content-Type"] = "application/octet-stream" })
1426
1444
  state.uploading = false
1427
1445
  if not response or not response.Success then
@@ -1474,6 +1492,7 @@ upload = function(state, overwrite)
1474
1492
  end
1475
1493
 
1476
1494
  local expectedHash = "*"
1495
+ if state.suppress then state.uploading = false return end
1477
1496
  if overwrite then
1478
1497
  expectedHash = state.manifest.exists and state.manifest.hash or "*"
1479
1498
  elseif state.lastHash then
@@ -2092,6 +2111,7 @@ local function createGroup(path, order, instanceIds, attention)
2092
2111
  return
2093
2112
  end
2094
2113
  bulkRunning = true
2114
+ while refreshing do task.wait() end
2095
2115
  for _, state in states do
2096
2116
  if group.attention and state.statusKind ~= "issue" then continue end
2097
2117
  if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
@@ -2105,12 +2125,15 @@ local function createGroup(path, order, instanceIds, attention)
2105
2125
  return
2106
2126
  end
2107
2127
  bulkRunning = true
2128
+ while refreshing do task.wait() end
2129
+ local affected = {}
2108
2130
  for _, state in states do
2109
2131
  if group.attention and state.statusKind ~= "issue" then continue end
2110
2132
  if pathStartsWith(group.path, state.manifest.studioPath, group.instanceIds, state.manifest.instanceIds) and not state.manifest.gitConflict then
2111
- pull(state)
2133
+ table.insert(affected, state)
2112
2134
  end
2113
2135
  end
2136
+ pullStates(affected)
2114
2137
  bulkRunning = false
2115
2138
  end)
2116
2139
  return group
@@ -3084,7 +3107,7 @@ local function manifestChanged(previous, current)
3084
3107
  or TreeRoots.pathKey(previous.studioPath, previous.instanceIds) ~= TreeRoots.pathKey(current.studioPath, current.instanceIds)
3085
3108
  end
3086
3109
 
3087
- refreshConfig = function()
3110
+ local function refreshConfigNow()
3088
3111
  local refreshStarted = os.clock()
3089
3112
  local response, requestError = request("GET", "/v1/config", nil, if configETag then { ["If-None-Match"] = configETag } else nil)
3090
3113
  if not response or (not response.Success and response.StatusCode ~= 304) then
@@ -3130,6 +3153,56 @@ refreshConfig = function()
3130
3153
  table.clear(groups)
3131
3154
  end
3132
3155
  serverConfig = decoded
3156
+ if decoded.restore and decoded.restore.status == "pending" then
3157
+ bulkRunning = true
3158
+ for _, state in states do
3159
+ state.generation += 1
3160
+ state.suppress = true
3161
+ end
3162
+ local prepared
3163
+ local success, failure = pcall(function()
3164
+ local base = "/v1/restore/" .. decoded.restore.id
3165
+ local response, failure = request("POST", base .. "/claim")
3166
+ assert(response and response.Success, failure or (response and responseError(response)) or "Restore download failed")
3167
+ connectionStatus.Text = "Restoring Studio from local files..."
3168
+ prepared = StudioRestore.prepare(response.Body)
3169
+ local authorized = request("POST", base .. "/commit")
3170
+ assert(authorized and authorized.Success, "Restore expired or Rojo disconnected; Studio was not replaced")
3171
+ for _, state in states do disconnectRoot(state) end
3172
+ disconnectSplitRoots()
3173
+ ChangeHistoryService:SetWaypoint("GoldSync before authoritative restore")
3174
+ StudioRestore.commit(prepared)
3175
+ ChangeHistoryService:SetWaypoint("GoldSync restore from local files")
3176
+ for _, state in states do
3177
+ destroyConflictWorkspace(state)
3178
+ destroyRootRow(state)
3179
+ end
3180
+ table.clear(states)
3181
+ table.clear(treeDiscovery)
3182
+ table.clear(savedHashes)
3183
+ for _, manifest in prepared.manifests do savedHashes[manifest.id] = manifest.hash end
3184
+ hashesDirty = true
3185
+ saveHashes()
3186
+ end)
3187
+ if not success and prepared then prepared.staging:Destroy() end
3188
+ local acknowledged = request("POST", "/v1/restore/" .. decoded.restore.id .. "/result", HttpService:JSONEncode({ success = success, error = if success then nil else tostring(failure) }), { ["Content-Type"] = "application/json" })
3189
+ bulkRunning = false
3190
+ configETag = nil
3191
+ if not success then
3192
+ for _, state in states do
3193
+ state.suppress = false
3194
+ state.root = nil
3195
+ state.observedRoot = nil
3196
+ end
3197
+ connectionStatus.Text = "Restore failed: " .. tostring(failure)
3198
+ elseif not acknowledged or not acknowledged.Success then
3199
+ connectionStatus.Text = "Studio restored, but the service did not acknowledge completion."
3200
+ else
3201
+ connectionStatus.Text = "Restored Studio from local files"
3202
+ end
3203
+ return success
3204
+ end
3205
+ if decoded.restore and (decoded.restore.status == "preparing" or decoded.restore.status == "running") then return true end
3133
3206
  if decoded.rojoConnected then
3134
3207
  connectionStatus.Text = "Connected · " .. decoded.name
3135
3208
  connectionStatus.TextColor3 = Color3.fromRGB(102, 214, 139)
@@ -3197,6 +3270,16 @@ refreshConfig = function()
3197
3270
  disconnectSplitRoot(id)
3198
3271
  end
3199
3272
 
3273
+ local presentRoots = {}
3274
+ for _, manifest in decoded.roots do
3275
+ if manifest.treeRootId and not manifest.container and not manifest.exists and not manifest.gitConflict
3276
+ and not resolveRoot(manifest.studioPath, manifest.instanceIds) then
3277
+ continue
3278
+ end
3279
+ table.insert(presentRoots, manifest)
3280
+ end
3281
+ decoded.roots = presentRoots
3282
+
3200
3283
  local boundaryPaths = {}
3201
3284
  for _, manifest in decoded.roots do
3202
3285
  local discovery = treeDiscovery[manifest.treeRootId]
@@ -3298,6 +3381,19 @@ refreshConfig = function()
3298
3381
  return true
3299
3382
  end
3300
3383
 
3384
+ refreshConfig = function()
3385
+ if bulkRunning or refreshing then return false end
3386
+ refreshing = true
3387
+ local success, result = pcall(refreshConfigNow)
3388
+ refreshing = false
3389
+ if not success then
3390
+ batchingStatusUpdates = false
3391
+ warn("GoldSync refresh failed: " .. tostring(result))
3392
+ return false
3393
+ end
3394
+ return result
3395
+ end
3396
+
3301
3397
  toolbarButton.Click:Connect(function()
3302
3398
  widget.Enabled = not widget.Enabled
3303
3399
  if widget.Enabled then
@@ -3328,6 +3424,100 @@ end)
3328
3424
  </Properties>
3329
3425
  </Item>
3330
3426
  <Item class="ModuleScript" referent="2">
3427
+ <Properties>
3428
+ <string name="Name">StudioRestore</string>
3429
+ <string name="Source"><![CDATA[local HttpService = game:GetService("HttpService")
3430
+ local SerializationService = game:GetService("SerializationService")
3431
+ local TreeRoots = require(script.Parent.TreeRoots)
3432
+
3433
+ local StudioRestore = {}
3434
+
3435
+ function StudioRestore.prepare(body)
3436
+ local staging = Instance.new("Folder")
3437
+ local success, result = pcall(function()
3438
+ local bytes = buffer.fromstring(body)
3439
+ local headerLength = buffer.readu32(bytes, 0)
3440
+ local packet = HttpService:JSONDecode(buffer.readstring(bytes, 4, headerLength))
3441
+ local offset = 4 + headerLength
3442
+ local instances, roots = {}, {}
3443
+ local trees = {}
3444
+ for _, tree in packet.trees do
3445
+ trees[tree.id] = TreeRoots.pathKey(tree.studioPath)
3446
+ end
3447
+ for _, manifest in packet.roots do
3448
+ local key = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds)
3449
+ assert(not instances[key], "Duplicate restore path")
3450
+ local isRoot = trees[manifest.treeRootId] == key
3451
+ local instance
3452
+ if manifest.exists then
3453
+ local restored = SerializationService:DeserializeInstancesAsync(buffer.fromstring(buffer.readstring(bytes, offset, manifest.size)))
3454
+ for _, candidate in restored do candidate.Parent = staging end
3455
+ assert(#restored == 1, "Snapshot must contain one root: " .. manifest.file)
3456
+ instance = restored[1]
3457
+ else
3458
+ assert(manifest.container and manifest.size == 0, "Missing asset snapshot: " .. manifest.file)
3459
+ instance = Instance.new("Folder")
3460
+ instance.Name = manifest.studioPath[#manifest.studioPath]
3461
+ local id = manifest.instanceIds[#manifest.studioPath]
3462
+ if id ~= "" then instance:SetAttribute("GoldSyncId", id) end
3463
+ instance.Parent = staging
3464
+ end
3465
+ offset += manifest.size
3466
+ assert(instance.Name == manifest.studioPath[#manifest.studioPath], "Snapshot name does not match " .. manifest.file)
3467
+ assert((instance:GetAttribute("GoldSyncId") or "") == manifest.instanceIds[#manifest.studioPath], "Snapshot identity does not match " .. manifest.file)
3468
+ assert(TreeRoots.isContainer(instance, isRoot) == manifest.container, "Snapshot class does not match " .. manifest.file)
3469
+ assert(not manifest.container or #instance:GetChildren() == 0, "Container snapshot contains children: " .. manifest.file)
3470
+ if isRoot then
3471
+ table.insert(roots, { instance = instance, path = manifest.studioPath })
3472
+ else
3473
+ local parentKey = TreeRoots.pathKey(manifest.studioPath, manifest.instanceIds, #manifest.studioPath - 1)
3474
+ assert(instances[parentKey], "Restore parent is missing: " .. manifest.file)
3475
+ instance.Parent = instances[parentKey]
3476
+ end
3477
+ instances[key] = instance
3478
+ end
3479
+ assert(offset == buffer.len(bytes), "Unexpected restore packet data")
3480
+ assert(#roots == #packet.trees, "Restore is missing a configured root")
3481
+ return { staging = staging, roots = roots, manifests = packet.roots }
3482
+ end)
3483
+ if not success then
3484
+ staging:Destroy()
3485
+ error(result)
3486
+ end
3487
+ return result
3488
+ end
3489
+
3490
+ function StudioRestore.commit(prepared)
3491
+ local previous = {}
3492
+ for _, root in prepared.roots do
3493
+ local parent = game
3494
+ for depth = 1, #root.path - 1 do
3495
+ parent = parent:FindFirstChild(root.path[depth])
3496
+ assert(parent, "Configured Studio parent is missing: " .. table.concat(root.path, "."))
3497
+ end
3498
+ root.parent = parent
3499
+ for _, child in parent:GetChildren() do
3500
+ if child.Name == root.instance.Name then table.insert(previous, { instance = child, parent = parent }) end
3501
+ end
3502
+ end
3503
+ local success, failure = pcall(function()
3504
+ for _, root in previous do root.instance.Parent = nil end
3505
+ for _, root in prepared.roots do root.instance.Parent = root.parent end
3506
+ end)
3507
+ if not success then
3508
+ for _, root in prepared.roots do root.instance.Parent = prepared.staging end
3509
+ for _, root in previous do root.instance.Parent = root.parent end
3510
+ error(failure)
3511
+ end
3512
+ for _, root in previous do root.instance:Destroy() end
3513
+ prepared.staging:Destroy()
3514
+ end
3515
+
3516
+ return StudioRestore
3517
+ ]]></string>
3518
+ </Properties>
3519
+ </Item>
3520
+ <Item class="ModuleScript" referent="3">
3331
3521
  <Properties>
3332
3522
  <string name="Name">TreeRoots</string>
3333
3523
  <string name="Source"><![CDATA[local TreeRoots = {}
@@ -5,11 +5,11 @@ import { access, copyFile, cp, mkdir, readFile, rm, writeFile } from "node:fs/pr
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
- import { findProjectConfig, registerProject } from "../src/companion.mjs";
8
+ import { findProjectConfig, registerProject, restoreStudio } from "../src/companion.mjs";
9
9
 
10
10
  const command = process.argv[2];
11
11
  if (command === "--help" || command === "-h") {
12
- console.log("Run 'goldsync install' from a repository containing goldsync.project.json.");
12
+ console.log("goldsync install\ngoldsync restore-studio --yes\nRun from your project folder. restore-studio replaces configured Studio roots with local files and discards their unsaved Studio changes.");
13
13
  process.exit(0);
14
14
  }
15
15
  if (command === "--version" || command === "-v") {
@@ -17,7 +17,7 @@ if (command === "--version" || command === "-v") {
17
17
  console.log(packageJson.version);
18
18
  process.exit(0);
19
19
  }
20
- if (command && command !== "install" && !command.startsWith("--")) {
20
+ if (command && command !== "install" && command !== "restore-studio" && !command.startsWith("--")) {
21
21
  throw new Error(`Unknown GoldSync command: ${command}`);
22
22
  }
23
23
 
@@ -36,6 +36,12 @@ const configPath = configIndex === -1
36
36
  ? await findProjectConfig(projectDirectory)
37
37
  : path.resolve(process.argv[configIndex + 1]);
38
38
 
39
+ if (command === "restore-studio") {
40
+ if (!process.argv.includes("--yes")) throw new Error("This replaces configured Studio roots and discards their unsaved changes. Run restore-studio --yes to proceed.");
41
+ await restoreStudio(configPath);
42
+ process.exit(0);
43
+ }
44
+
39
45
  const dataDirectory = path.join(process.env.LOCALAPPDATA, "GoldSync");
40
46
  const runtimeDirectory = path.join(dataDirectory, "runtime");
41
47
  const installedAgent = path.join(runtimeDirectory, "bin", "goldsync-agent.mjs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goldsync",
3
- "version": "0.1.31",
3
+ "version": "0.1.34",
4
4
  "description": "Automatic native Roblox asset synchronization for Rojo projects.",
5
5
  "author": "tay",
6
6
  "license": "SEE LICENSE IN LICENSE.txt",
package/src/broker.mjs CHANGED
@@ -237,7 +237,23 @@ export class GoldSyncBroker {
237
237
 
238
238
  async handle(request, response) {
239
239
  const url = new URL(request.url, `http://${this.host}:${this.port}`);
240
- const managementRequest = MANAGEMENT_CLIENTS.has(request.headers["x-goldsync-client"]);
240
+ const managementRequest = request.headers.origin === undefined && MANAGEMENT_CLIENTS.has(request.headers["x-goldsync-client"]);
241
+ if (managementRequest && url.pathname === "/v1/workspaces/restore-studio") {
242
+ const body = request.method === "POST" ? await readJson(request) : null;
243
+ const runtime = this.workspaces.get(body?.workspaceId ?? url.searchParams.get("workspaceId"));
244
+ if (!runtime) { sendJson(response, 404, { error: "GoldSync project is not registered" }); return; }
245
+ if (request.method === "POST") {
246
+ if (body.confirm !== true) { sendJson(response, 400, { error: "restore-studio requires --yes" }); return; }
247
+ sendJson(response, 200, await runtime.server.beginRestore());
248
+ } else if (request.method === "GET") {
249
+ const status = runtime.server.restoreStatus();
250
+ if (!status || status.id !== url.searchParams.get("id")) { sendJson(response, 404, { error: "Restore operation no longer exists" }); return; }
251
+ sendJson(response, 200, status);
252
+ } else {
253
+ sendJson(response, 405, { error: "Method not allowed" });
254
+ }
255
+ return;
256
+ }
241
257
  if (managementRequest && request.method === "GET" && url.pathname === "/v1/health") {
242
258
  sendJson(response, 200, {
243
259
  version: 1,
package/src/companion.mjs CHANGED
@@ -4,6 +4,7 @@ import http from "node:http";
4
4
  import path from "node:path";
5
5
  import { GoldSyncBroker } from "./broker.mjs";
6
6
  import { loadConfig } from "./config.mjs";
7
+ import { setTimeout as delay } from "node:timers/promises";
7
8
 
8
9
  export const BROKER_PORT = 34873;
9
10
  export const HEARTBEAT_MS = 5000;
@@ -26,7 +27,7 @@ function request(method, requestPath, body, port = BROKER_PORT) {
26
27
  }
27
28
  : {}),
28
29
  },
29
- timeout: requestPath === "/v1/workspaces/register" ? 120000 : 2000,
30
+ timeout: requestPath === "/v1/workspaces/register" || (method === "POST" && requestPath === "/v1/workspaces/restore-studio") ? 120000 : 2000,
30
31
  },
31
32
  (response) => {
32
33
  const chunks = [];
@@ -59,6 +60,20 @@ function request(method, requestPath, body, port = BROKER_PORT) {
59
60
  });
60
61
  }
61
62
 
63
+ export async function restoreStudio(configPath) {
64
+ const workspace = await request("POST", "/v1/workspaces/register", { configPath, clientId: randomUUID() });
65
+ let job = await request("POST", "/v1/workspaces/restore-studio", { workspaceId: workspace.workspaceId, confirm: true });
66
+ console.log("Replacing configured Studio roots with local assets. Unsaved Studio changes in those roots will be removed.");
67
+ const deadline = Date.now() + 330000;
68
+ while (["pending", "running", "preparing"].includes(job.status)) {
69
+ if (Date.now() > deadline) throw new Error("Restore did not finish. Check Studio before retrying.");
70
+ await delay(500);
71
+ job = await request("GET", `/v1/workspaces/restore-studio?workspaceId=${encodeURIComponent(workspace.workspaceId)}&id=${encodeURIComponent(job.id)}`);
72
+ }
73
+ if (job.status !== "completed") throw new Error(job.error ?? "Studio restore failed");
74
+ console.log("Studio now matches the local asset files. Stale instances and previous sync state were removed.");
75
+ }
76
+
62
77
  async function fileExists(file) {
63
78
  try {
64
79
  await access(file);
@@ -0,0 +1,34 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { SnapshotStore } from "./store.mjs";
3
+ import { GitConflictStore } from "./git-conflicts.mjs";
4
+
5
+ export async function prepareRestore(config) {
6
+ if (config.version !== 2 || !config.treeRoots?.length || config.roots.length || config.splitRoots?.length) {
7
+ throw new Error("restore-studio requires a project containing only version 2 tree roots");
8
+ }
9
+ for (const tree of config.treeRoots) {
10
+ if (!(await stat(tree.absoluteDirectory)).isDirectory()) throw new Error(`Missing asset directory: ${tree.directory}`);
11
+ }
12
+ const store = new SnapshotStore(config);
13
+ await store.initialize();
14
+ const conflicts = new GitConflictStore(config);
15
+ await conflicts.initialize();
16
+ conflicts.syncRoots(store.getRoots());
17
+ if ((await conflicts.list()).size) throw new Error("Resolve asset Git conflicts before restoring Studio");
18
+ const roots = store.getManifests();
19
+ roots.sort((left, right) => left.studioPath.length - right.studioPath.length || left.id.localeCompare(right.id));
20
+ const chunks = [];
21
+ let size = 0;
22
+ for (const root of roots) {
23
+ const snapshot = await store.get(root.id);
24
+ if (root.exists && (!snapshot || snapshot.hash !== root.hash)) throw new Error(`File changed during restore preparation: ${root.file}`);
25
+ root.size = snapshot?.bytes.length ?? 0;
26
+ if (snapshot) chunks.push(snapshot.bytes);
27
+ size += root.size;
28
+ if (size > 128 * 1024 * 1024) throw new Error("Restore exceeds the 128 MiB limit");
29
+ }
30
+ const header = Buffer.from(JSON.stringify({ roots, trees: config.treeRoots.map(({ id, studioPath }) => ({ id, studioPath })) }));
31
+ const length = Buffer.alloc(4);
32
+ length.writeUInt32LE(header.length);
33
+ return { store, packet: Buffer.concat([length, header, ...chunks]) };
34
+ }
package/src/server.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import { watch } from "node:fs";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { createServer } from "node:http";
4
4
  import path from "node:path";
5
5
  import { ConflictError } from "./store.mjs";
6
+ import { prepareRestore } from "./restore.mjs";
6
7
 
7
8
  const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
8
9
 
@@ -66,9 +67,50 @@ export class GoldSyncServer {
66
67
  }
67
68
 
68
69
  setSessionToken(token) {
70
+ if (token !== this.sessionToken && this.restoreJob) {
71
+ this.restoreJob.status = "failed";
72
+ this.restoreJob.error = "Rojo session changed; run restore-studio again";
73
+ this.restoreJob.packet = null;
74
+ this.restoreJob.store = null;
75
+ }
76
+ if (token !== this.sessionToken) this.lastRestoreClient = null;
69
77
  this.sessionToken = token;
70
78
  }
71
79
 
80
+ restoreStatus() {
81
+ const job = this.restoreJob;
82
+ if (!job) return null;
83
+ if ((job.status === "pending" && Date.now() - job.createdAt > 15000)
84
+ || (job.status === "running" && Date.now() - job.createdAt > 300000)) {
85
+ job.status = "failed";
86
+ job.error = "Studio restore timed out. Check Studio, update the plugin and reconnect before retrying.";
87
+ job.packet = null;
88
+ job.store = null;
89
+ }
90
+ return { id: job.id, status: job.status, error: job.error };
91
+ }
92
+
93
+ async beginRestore() {
94
+ if (!this.syncEnabled || !this.sessionToken) throw new Error("Connect Studio through Rojo before restoring");
95
+ if (!this.lastRestoreClient || Date.now() - this.lastRestoreClient > 10000) throw new Error("Open Studio with GoldSync 0.1.34 or newer and connect Rojo first");
96
+ if (this.store.boundaryChange || ["preparing", "pending", "running"].includes(this.restoreStatus()?.status)) throw new Error("A sync operation is already running");
97
+ const job = { id: randomUUID(), status: "preparing", createdAt: Date.now() };
98
+ this.restoreJob = job;
99
+ try {
100
+ Object.assign(job, await prepareRestore(this.config));
101
+ if (job.status !== "preparing") throw new Error("Rojo session changed while preparing restore");
102
+ job.status = "pending";
103
+ job.createdAt = Date.now();
104
+ } catch (error) {
105
+ job.status = "failed";
106
+ job.error = error.message;
107
+ job.packet = null;
108
+ job.store = null;
109
+ throw error;
110
+ }
111
+ return this.restoreStatus();
112
+ }
113
+
72
114
  async start(options = {}) {
73
115
  if (options.listen !== false) {
74
116
  await new Promise((resolve, reject) => {
@@ -157,6 +199,46 @@ export class GoldSyncServer {
157
199
  return;
158
200
  }
159
201
  const url = new URL(request.url, `http://${this.config.host}:${this.config.port}`);
202
+ if (request.headers["x-goldsync-restore"] === "1") this.lastRestoreClient = Date.now();
203
+ const restoreMatch = /^\/v1\/restore\/([a-f0-9-]+)\/(claim|commit|result)$/.exec(url.pathname);
204
+ if (request.method === "POST" && restoreMatch) {
205
+ const job = this.restoreJob;
206
+ const status = this.restoreStatus();
207
+ if (job?.id === restoreMatch[1] && restoreMatch[2] === "result" && ["completed", "failed"].includes(status.status)) {
208
+ sendJson(response, 200, status); return;
209
+ }
210
+ if (!this.syncEnabled || !job || job.id !== restoreMatch[1] || !["pending", "running"].includes(status.status)) {
211
+ sendJson(response, 409, { error: "Restore is no longer active" }); return;
212
+ }
213
+ if (restoreMatch[2] === "claim") {
214
+ if (job.status !== "pending") { sendJson(response, 409, { error: "Restore was already claimed" }); return; }
215
+ job.status = "running";
216
+ job.createdAt = Date.now();
217
+ response.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": job.packet.length });
218
+ response.end(job.packet);
219
+ return;
220
+ }
221
+ if (job.status !== "running") { sendJson(response, 409, { error: "Claim the restore first" }); return; }
222
+ if (restoreMatch[2] === "result") {
223
+ const result = JSON.parse((await readBody(request)).toString("utf8"));
224
+ if (typeof result.success !== "boolean") { sendJson(response, 400, { error: "Expected restore result" }); return; }
225
+ job.status = result.success ? "completed" : "failed";
226
+ job.error = result.success ? undefined : String(result.error ?? "Studio restore failed").slice(0, 2000);
227
+ if (result.success) {
228
+ this.store = job.store;
229
+ this.store.treeScanNeeded = true;
230
+ this.gitConflicts?.syncRoots(this.store.getRoots());
231
+ }
232
+ job.packet = null;
233
+ job.store = null;
234
+ }
235
+ sendJson(response, 200, this.restoreStatus());
236
+ return;
237
+ }
238
+ const restore = this.restoreStatus();
239
+ if (["preparing", "pending", "running"].includes(restore?.status) && url.pathname !== "/v1/config") {
240
+ sendJson(response, 409, { error: "Authoritative Studio restore in progress" }); return;
241
+ }
160
242
  if (this.store.boundaryChange) {
161
243
  sendJson(response, 409, { error: "Boundary conversion in progress; retry after it finishes" });
162
244
  return;
@@ -204,6 +286,7 @@ export class GoldSyncServer {
204
286
  pollIntervalMs: this.config.pollIntervalMs,
205
287
  debounceMs: this.config.debounceMs,
206
288
  rojoConnected: this.syncEnabled,
289
+ restore,
207
290
  treeRoots: (this.config.treeRoots ?? []).map((root) => ({ id: root.id, studioPath: root.studioPath })),
208
291
  splitRoots: (this.config.splitRoots ?? []).map((root) => ({
209
292
  id: root.id,
package/src/store.mjs CHANGED
@@ -363,20 +363,35 @@ export class SnapshotStore {
363
363
  const entries = [];
364
364
  const visit = async (directory, relativePath, instanceIds) => {
365
365
  if (tree.studioPath.length + relativePath.length > this.config.maxPathDepth) throw new Error(`tree root ${tree.id} exceeds maxPathDepth`);
366
- entries.push({ path: relativePath, instanceIds, container: true });
366
+ const start = entries.length;
367
+ let metadata = false;
367
368
  for (const entry of await readdir(directory, { withFileTypes: true })) {
368
369
  if (entry.isSymbolicLink()) throw new Error(`tree roots cannot contain symbolic links: ${entry.name}`);
369
370
  if (entry.isDirectory()) {
370
371
  const segment = decodeTreeSegment(entry.name);
371
372
  await visit(path.join(directory, entry.name), [...relativePath, segment.name], [...instanceIds, segment.id]);
372
- } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".rbxm") && entry.name !== "_root.rbxm") {
373
+ } else if (entry.isFile() && entry.name === "_root.rbxm") {
374
+ metadata = true;
375
+ } else if (entry.isFile() && entry.name.toLowerCase().endsWith(".rbxm")) {
373
376
  const segment = decodeTreeSegment(entry.name.slice(0, -5));
374
377
  entries.push({ path: [...relativePath, segment.name], instanceIds: [...instanceIds, segment.id], container: false });
375
378
  }
376
379
  }
380
+ if (relativePath.length === 0 || metadata || entries.length > start) {
381
+ entries.push({ path: relativePath, instanceIds, container: true });
382
+ }
377
383
  };
378
384
  await visit(tree.absoluteDirectory, [], []);
385
+ const containers = new Set(entries.filter(entry => entry.container)
386
+ .map(entry => this.makeTreeRoot(tree, entry.path, true, entry.instanceIds).id));
387
+ for (const [id, root] of this.roots) {
388
+ if (root.treeRootId !== tree.id || !root.diskContainer || containers.has(id)) continue;
389
+ this.roots.delete(id);
390
+ this.rootsByFile.delete(root.absoluteFile.toLowerCase());
391
+ this.manifestRoots = null;
392
+ }
379
393
  await this.discoverTreePaths(tree.id, entries);
394
+ for (const id of containers) this.roots.get(id).diskContainer = true;
380
395
  }
381
396
  }
382
397