goldsync 0.1.16 → 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 +69 -78
- package/assets/GoldSync.rbxmx +1206 -204
- package/bin/install-companion.mjs +38 -6
- package/media/configuration-assets.png +0 -0
- package/media/file-boundaries.png +0 -0
- package/package.json +2 -2
- package/src/broker.mjs +17 -1
- package/src/companion.mjs +16 -1
- package/src/config.mjs +39 -6
- package/src/git-conflicts.mjs +1 -1
- package/src/restore.mjs +34 -0
- package/src/server.mjs +191 -13
- package/src/store.mjs +246 -5
package/src/store.mjs
CHANGED
|
@@ -29,6 +29,17 @@ function decodePathSegment(segment) {
|
|
|
29
29
|
);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function decodeTreeSegment(segment) {
|
|
33
|
+
const numbered = /^(.*) \(([1-9][0-9]{0,8})\)$/.exec(segment);
|
|
34
|
+
if (numbered) return { name: decodePathSegment(numbered[1]), id: numbered[2] };
|
|
35
|
+
const match = /^(.*)~([0-9a-f]{32})$/.exec(segment);
|
|
36
|
+
return { name: decodePathSegment(match ? match[1] : segment), id: match?.[2] ?? "" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function treePathKey(studioPath, instanceIds = []) {
|
|
40
|
+
return JSON.stringify(studioPath.map((name, index) => [name, instanceIds[index] ?? ""]));
|
|
41
|
+
}
|
|
42
|
+
|
|
32
43
|
async function findSplitFiles(directory, depth, relative = []) {
|
|
33
44
|
let entries;
|
|
34
45
|
try {
|
|
@@ -92,9 +103,11 @@ export class SnapshotStore {
|
|
|
92
103
|
constructor(config) {
|
|
93
104
|
this.config = config;
|
|
94
105
|
this.roots = new Map(config.roots.map((root) => [root.id, root]));
|
|
106
|
+
this.rootsByFile = new Map(config.roots.map((root) => [root.absoluteFile.toLowerCase(), root]));
|
|
95
107
|
this.stateFile = path.join(config.stateDirectory, "state.json");
|
|
96
108
|
this.backupRoot = path.join(config.stateDirectory, "backups");
|
|
97
109
|
this.state = { version: 1, roots: {} };
|
|
110
|
+
this.treeScanNeeded = true;
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
makeSplitRoot(splitRoot, relativePath) {
|
|
@@ -128,6 +141,8 @@ export class SnapshotStore {
|
|
|
128
141
|
return false;
|
|
129
142
|
}
|
|
130
143
|
this.roots.set(root.id, root);
|
|
144
|
+
this.manifestRoots = null;
|
|
145
|
+
this.rootsByFile.set(root.absoluteFile.toLowerCase(), root);
|
|
131
146
|
await mkdir(path.dirname(root.absoluteFile), { recursive: true });
|
|
132
147
|
await this.refresh(root.id, false);
|
|
133
148
|
return true;
|
|
@@ -154,6 +169,7 @@ export class SnapshotStore {
|
|
|
154
169
|
}
|
|
155
170
|
|
|
156
171
|
async refreshSplitRoots() {
|
|
172
|
+
await this.refreshTreeRoots();
|
|
157
173
|
for (const splitRoot of this.config.splitRoots ?? []) {
|
|
158
174
|
await mkdir(splitRoot.absoluteDirectory, { recursive: true });
|
|
159
175
|
const paths = await findSplitFiles(splitRoot.absoluteDirectory, splitRoot.splitDepth);
|
|
@@ -161,6 +177,224 @@ export class SnapshotStore {
|
|
|
161
177
|
}
|
|
162
178
|
}
|
|
163
179
|
|
|
180
|
+
makeTreeRoot(tree, relativePath, container, relativeIds) {
|
|
181
|
+
if (!Array.isArray(relativePath) || typeof container !== "boolean"
|
|
182
|
+
|| tree.studioPath.length + relativePath.length > this.config.maxPathDepth
|
|
183
|
+
|| relativePath.some((segment) => typeof segment !== "string" || !segment || segment === "." || segment === ".." || segment.toLowerCase() === "_root")) {
|
|
184
|
+
throw new Error(`tree root ${tree.id} received an invalid path`);
|
|
185
|
+
}
|
|
186
|
+
if (relativePath.length === 0 && !container) throw new Error("tree roots must be containers");
|
|
187
|
+
relativeIds ??= relativePath.map(() => "");
|
|
188
|
+
if (!Array.isArray(relativeIds) || relativeIds.length !== relativePath.length
|
|
189
|
+
|| relativeIds.some((id) => typeof id !== "string" || (id !== "" && !/^(?:[1-9][0-9]{0,8}|[0-9a-f]{32})$/.test(id)))) {
|
|
190
|
+
throw new Error("instanceIds must match the path and contain valid sibling identifiers");
|
|
191
|
+
}
|
|
192
|
+
const studioPath = [...tree.studioPath, ...relativePath];
|
|
193
|
+
const instanceIds = [...tree.studioPath.map(() => ""), ...relativeIds];
|
|
194
|
+
const identity = relativeIds.some(Boolean) ? [studioPath, instanceIds] : studioPath;
|
|
195
|
+
const suffix = createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 16);
|
|
196
|
+
const directory = path.join(tree.directory, ...relativePath.map((name, index) =>
|
|
197
|
+
encodePathSegment(name).replaceAll("~", "%7e").replaceAll("(", "%28").replaceAll(")", "%29")
|
|
198
|
+
+ (relativeIds[index] ? (relativeIds[index].length <= 9 ? ` (${relativeIds[index]})` : `~${relativeIds[index]}`) : "")));
|
|
199
|
+
const file = (container ? path.join(directory, "_root.rbxm") : directory + ".rbxm").replaceAll(path.sep, "/");
|
|
200
|
+
return {
|
|
201
|
+
id: `${tree.id}-${suffix}`, studioPath, file,
|
|
202
|
+
absoluteFile: path.resolve(this.config.projectRoot, file),
|
|
203
|
+
treeRootId: tree.id, container, instanceIds,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async discoverTreePaths(treeId, entries) {
|
|
208
|
+
const tree = (this.config.treeRoots ?? []).find((candidate) => candidate.id === treeId);
|
|
209
|
+
if (!tree) throw new Error(`unknown tree root: ${treeId}`);
|
|
210
|
+
const roots = entries.map((entry) => this.makeTreeRoot(tree, entry.path, entry.container, entry.instanceIds));
|
|
211
|
+
const byPath = new Map();
|
|
212
|
+
const byFile = new Map();
|
|
213
|
+
for (const root of [...this.roots.values(), ...roots]) {
|
|
214
|
+
const key = treePathKey(root.studioPath, root.instanceIds);
|
|
215
|
+
const existing = byPath.get(key);
|
|
216
|
+
if (existing && existing.container !== root.container) throw new Error(`conflicting file and directory at ${root.file}`);
|
|
217
|
+
const fileKey = root.absoluteFile.toLowerCase();
|
|
218
|
+
if (byFile.has(fileKey) && byFile.get(fileKey) !== key) throw new Error(`duplicate tree output file: ${root.file}`);
|
|
219
|
+
byPath.set(key, root);
|
|
220
|
+
byFile.set(fileKey, key);
|
|
221
|
+
}
|
|
222
|
+
for (const root of roots) {
|
|
223
|
+
for (let depth = 2; depth < root.studioPath.length; depth += 1) {
|
|
224
|
+
const ancestor = byPath.get(treePathKey(root.studioPath.slice(0, depth), root.instanceIds));
|
|
225
|
+
if (ancestor && ancestor.container === false) throw new Error(`tree path is inside an existing asset: ${root.file}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
let changed = false;
|
|
229
|
+
for (const root of roots) changed = (await this.addRoot(root)) || changed;
|
|
230
|
+
if (changed) await this.persistState();
|
|
231
|
+
return changed;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async replaceBoundary(id, entries, expected) {
|
|
235
|
+
if (this.treeScan) await this.treeScan;
|
|
236
|
+
if (this.boundaryChange) throw new Error("Another boundary conversion is running");
|
|
237
|
+
const original = this.getRoot(id);
|
|
238
|
+
const tree = this.config.treeRoots.find((candidate) => candidate.id === original.treeRootId);
|
|
239
|
+
if (!tree || !Array.isArray(entries)) throw new Error("Invalid boundary conversion");
|
|
240
|
+
const removing = entries.length === 0;
|
|
241
|
+
if (removing && original.studioPath.length === tree.studioPath.length) throw new Error("Cannot remove a configured tree root");
|
|
242
|
+
const prefix = treePathKey(original.studioPath, original.instanceIds);
|
|
243
|
+
const affected = this.getRoots().filter((root) => root.treeRootId === tree.id
|
|
244
|
+
&& treePathKey(root.studioPath.slice(0, original.studioPath.length), root.instanceIds) === prefix);
|
|
245
|
+
const replacements = entries.map((entry) => ({
|
|
246
|
+
root: this.makeTreeRoot(tree, entry.path, entry.container, entry.instanceIds), bytes: entry.bytes,
|
|
247
|
+
}));
|
|
248
|
+
const replacement = replacements.find(({ root }) => root.id === id)?.root;
|
|
249
|
+
if (!removing && (!replacement || replacement.container === original.container
|
|
250
|
+
|| (!replacement.container && replacements.length !== 1))) throw new Error("Expected a changed boundary");
|
|
251
|
+
const destination = removing ? null : replacement.container ? path.dirname(replacement.absoluteFile) : replacement.absoluteFile;
|
|
252
|
+
const source = original.container ? path.dirname(original.absoluteFile) : original.absoluteFile;
|
|
253
|
+
const outputFiles = new Set();
|
|
254
|
+
const byPath = new Map(replacements.map(({ root }) => [treePathKey(root.studioPath, root.instanceIds), root]));
|
|
255
|
+
for (const { root, bytes } of replacements) {
|
|
256
|
+
const relative = path.relative(replacement.container ? destination : path.dirname(destination), root.absoluteFile);
|
|
257
|
+
if (treePathKey(root.studioPath.slice(0, original.studioPath.length), root.instanceIds) !== prefix
|
|
258
|
+
|| relative.startsWith("..") || path.isAbsolute(relative) || outputFiles.has(root.absoluteFile.toLowerCase())
|
|
259
|
+
|| !Buffer.isBuffer(bytes) || (!root.container && bytes.length === 0)) throw new Error("Invalid replacement snapshot");
|
|
260
|
+
outputFiles.add(root.absoluteFile.toLowerCase());
|
|
261
|
+
if (root.id !== id) {
|
|
262
|
+
const parent = byPath.get(treePathKey(root.studioPath.slice(0, -1), root.instanceIds));
|
|
263
|
+
if (!parent?.container) throw new Error("Replacement child must have a container parent");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (affected.length !== Object.keys(expected ?? {}).length) throw new ConflictError(this.getManifest(id));
|
|
267
|
+
const transaction = path.join(this.backupRoot, `boundary-${Date.now()}-${randomUUID()}`);
|
|
268
|
+
const staged = path.join(transaction, "new");
|
|
269
|
+
const backup = path.join(transaction, "original");
|
|
270
|
+
const previousRoots = new Map(this.roots);
|
|
271
|
+
const previousFiles = new Map(this.rootsByFile);
|
|
272
|
+
const previousState = structuredClone(this.state);
|
|
273
|
+
this.boundaryChange = true;
|
|
274
|
+
let moved = false;
|
|
275
|
+
let installed = false;
|
|
276
|
+
try {
|
|
277
|
+
if (original.container) {
|
|
278
|
+
const tracked = new Set(affected.map((root) => root.absoluteFile.toLowerCase()));
|
|
279
|
+
const inspect = async (directory) => {
|
|
280
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
281
|
+
const file = path.join(directory, entry.name);
|
|
282
|
+
if (entry.isSymbolicLink() || (!entry.isDirectory() && !tracked.has(file.toLowerCase()))) throw new Error(`Untracked content prevents conversion: ${entry.name}`);
|
|
283
|
+
if (entry.isDirectory()) await inspect(file);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
await inspect(source);
|
|
287
|
+
}
|
|
288
|
+
await mkdir(transaction, { recursive: true });
|
|
289
|
+
if (replacement?.container) await mkdir(staged);
|
|
290
|
+
for (const { root, bytes } of replacements) {
|
|
291
|
+
const file = replacement.container ? path.join(staged, path.relative(destination, root.absoluteFile)) : staged;
|
|
292
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
293
|
+
if (bytes.length) await atomicWrite(file, bytes);
|
|
294
|
+
}
|
|
295
|
+
if (!removing) {
|
|
296
|
+
try {
|
|
297
|
+
await stat(destination);
|
|
298
|
+
throw new Error("Replacement destination already exists");
|
|
299
|
+
} catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
300
|
+
}
|
|
301
|
+
for (const root of affected) {
|
|
302
|
+
const snapshot = await readSnapshot(root.absoluteFile);
|
|
303
|
+
if (!Object.hasOwn(expected, root.id) || (snapshot?.hash ?? false) !== expected[root.id]) throw new ConflictError(this.getManifest(root.id));
|
|
304
|
+
}
|
|
305
|
+
await rename(source, backup);
|
|
306
|
+
moved = true;
|
|
307
|
+
if (!removing) {
|
|
308
|
+
await rename(staged, destination);
|
|
309
|
+
installed = true;
|
|
310
|
+
}
|
|
311
|
+
for (const root of affected) {
|
|
312
|
+
this.roots.delete(root.id);
|
|
313
|
+
this.rootsByFile.delete(root.absoluteFile.toLowerCase());
|
|
314
|
+
delete this.state.roots[root.id];
|
|
315
|
+
}
|
|
316
|
+
for (const { root } of replacements) {
|
|
317
|
+
this.roots.set(root.id, root);
|
|
318
|
+
this.rootsByFile.set(root.absoluteFile.toLowerCase(), root);
|
|
319
|
+
const snapshot = await readSnapshot(root.absoluteFile);
|
|
320
|
+
this.state.roots[root.id] = {
|
|
321
|
+
exists: snapshot !== null, deleted: false, hash: snapshot?.hash ?? null,
|
|
322
|
+
revision: (previousState.roots[root.id]?.revision ?? 0) + 1,
|
|
323
|
+
size: snapshot?.size ?? 0, mtimeMs: snapshot?.mtimeMs ?? null,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
this.manifestRoots = null;
|
|
327
|
+
await this.persistState();
|
|
328
|
+
this.treeScanNeeded = true;
|
|
329
|
+
return replacements.map(({ root }) => this.getManifest(root.id));
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (installed) await rename(destination, staged);
|
|
332
|
+
if (moved) await rename(backup, source);
|
|
333
|
+
this.roots = previousRoots;
|
|
334
|
+
this.rootsByFile = previousFiles;
|
|
335
|
+
this.state = previousState;
|
|
336
|
+
this.manifestRoots = null;
|
|
337
|
+
throw error;
|
|
338
|
+
} finally {
|
|
339
|
+
this.boundaryChange = false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async refreshTreeRoots() {
|
|
344
|
+
if (this.boundaryChange) return;
|
|
345
|
+
if (this.treeScan) return this.treeScan;
|
|
346
|
+
if (!this.treeScanNeeded && Date.now() < this.nextTreeScan) return;
|
|
347
|
+
this.treeScanNeeded = false;
|
|
348
|
+
this.treeScan = this.scanTreeRoots();
|
|
349
|
+
try {
|
|
350
|
+
await this.treeScan;
|
|
351
|
+
this.nextTreeScan = Date.now() + 30000;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
this.treeScanNeeded = true;
|
|
354
|
+
throw error;
|
|
355
|
+
} finally {
|
|
356
|
+
this.treeScan = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async scanTreeRoots() {
|
|
361
|
+
for (const tree of this.config.treeRoots ?? []) {
|
|
362
|
+
await mkdir(tree.absoluteDirectory, { recursive: true });
|
|
363
|
+
const entries = [];
|
|
364
|
+
const visit = async (directory, relativePath, instanceIds) => {
|
|
365
|
+
if (tree.studioPath.length + relativePath.length > this.config.maxPathDepth) throw new Error(`tree root ${tree.id} exceeds maxPathDepth`);
|
|
366
|
+
const start = entries.length;
|
|
367
|
+
let metadata = false;
|
|
368
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
369
|
+
if (entry.isSymbolicLink()) throw new Error(`tree roots cannot contain symbolic links: ${entry.name}`);
|
|
370
|
+
if (entry.isDirectory()) {
|
|
371
|
+
const segment = decodeTreeSegment(entry.name);
|
|
372
|
+
await visit(path.join(directory, entry.name), [...relativePath, segment.name], [...instanceIds, segment.id]);
|
|
373
|
+
} else if (entry.isFile() && entry.name === "_root.rbxm") {
|
|
374
|
+
metadata = true;
|
|
375
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(".rbxm")) {
|
|
376
|
+
const segment = decodeTreeSegment(entry.name.slice(0, -5));
|
|
377
|
+
entries.push({ path: [...relativePath, segment.name], instanceIds: [...instanceIds, segment.id], container: false });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (relativePath.length === 0 || metadata || entries.length > start) {
|
|
381
|
+
entries.push({ path: relativePath, instanceIds, container: true });
|
|
382
|
+
}
|
|
383
|
+
};
|
|
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
|
+
}
|
|
393
|
+
await this.discoverTreePaths(tree.id, entries);
|
|
394
|
+
for (const id of containers) this.roots.get(id).diskContainer = true;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
164
398
|
async initialize() {
|
|
165
399
|
await mkdir(this.config.stateDirectory, { recursive: true });
|
|
166
400
|
try {
|
|
@@ -204,13 +438,16 @@ export class SnapshotStore {
|
|
|
204
438
|
size: current?.size ?? 0,
|
|
205
439
|
mtimeMs: current?.mtimeMs ?? null,
|
|
206
440
|
splitRootId: root.splitRootId ?? null,
|
|
441
|
+
treeRootId: root.treeRootId ?? null,
|
|
442
|
+
container: root.container === true,
|
|
443
|
+
instanceIds: root.instanceIds ?? null,
|
|
207
444
|
};
|
|
208
445
|
}
|
|
209
446
|
|
|
210
447
|
getManifests() {
|
|
211
|
-
|
|
212
|
-
.sort((left, right) => left.studioPath.join(".").localeCompare(right.studioPath.join(".")))
|
|
213
|
-
|
|
448
|
+
this.manifestRoots ??= [...this.roots.values()]
|
|
449
|
+
.sort((left, right) => left.studioPath.join(".").localeCompare(right.studioPath.join(".")));
|
|
450
|
+
return this.manifestRoots.map((root) => this.getManifest(root.id));
|
|
214
451
|
}
|
|
215
452
|
|
|
216
453
|
getRoots() {
|
|
@@ -258,8 +495,9 @@ export class SnapshotStore {
|
|
|
258
495
|
return this.getManifest(id);
|
|
259
496
|
}
|
|
260
497
|
|
|
261
|
-
async delete(id, expectedHash) {
|
|
498
|
+
async delete(id, expectedHash, plainFolder = false) {
|
|
262
499
|
const root = this.getRoot(id);
|
|
500
|
+
if (root.container && !plainFolder) throw new Error("container snapshots must be restored or removed through the project configuration");
|
|
263
501
|
const current = await readSnapshot(root.absoluteFile);
|
|
264
502
|
if (!current || expectedHash !== current.hash) {
|
|
265
503
|
throw new ConflictError(this.getManifest(id));
|
|
@@ -270,7 +508,7 @@ export class SnapshotStore {
|
|
|
270
508
|
const previousRevision = this.state.roots[id]?.revision ?? 0;
|
|
271
509
|
this.state.roots[id] = {
|
|
272
510
|
exists: false,
|
|
273
|
-
deleted:
|
|
511
|
+
deleted: !root.container,
|
|
274
512
|
hash: null,
|
|
275
513
|
revision: previousRevision + 1,
|
|
276
514
|
size: 0,
|
|
@@ -279,6 +517,8 @@ export class SnapshotStore {
|
|
|
279
517
|
const deletedManifest = this.getManifest(id);
|
|
280
518
|
if (root.splitRootId) {
|
|
281
519
|
this.roots.delete(id);
|
|
520
|
+
this.manifestRoots = null;
|
|
521
|
+
this.rootsByFile.delete(root.absoluteFile.toLowerCase());
|
|
282
522
|
delete this.state.roots[id];
|
|
283
523
|
}
|
|
284
524
|
await this.persistState();
|
|
@@ -286,6 +526,7 @@ export class SnapshotStore {
|
|
|
286
526
|
}
|
|
287
527
|
|
|
288
528
|
async refresh(id, persist = true) {
|
|
529
|
+
if (this.boundaryChange || !this.roots.has(id)) return false;
|
|
289
530
|
const root = this.getRoot(id);
|
|
290
531
|
const snapshot = await readSnapshot(root.absoluteFile);
|
|
291
532
|
const previous = this.state.roots[id];
|