goldsync 0.1.16 → 0.1.31
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 +60 -79
- package/assets/GoldSync.rbxmx +1006 -194
- package/bin/install-companion.mjs +29 -3
- package/media/configuration-assets.png +0 -0
- package/media/file-boundaries.png +0 -0
- package/package.json +2 -2
- package/src/companion.mjs +1 -1
- package/src/config.mjs +39 -6
- package/src/git-conflicts.mjs +1 -1
- package/src/server.mjs +108 -13
- package/src/store.mjs +231 -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,209 @@ 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
|
+
entries.push({ path: relativePath, instanceIds, container: true });
|
|
367
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
368
|
+
if (entry.isSymbolicLink()) throw new Error(`tree roots cannot contain symbolic links: ${entry.name}`);
|
|
369
|
+
if (entry.isDirectory()) {
|
|
370
|
+
const segment = decodeTreeSegment(entry.name);
|
|
371
|
+
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
|
+
const segment = decodeTreeSegment(entry.name.slice(0, -5));
|
|
374
|
+
entries.push({ path: [...relativePath, segment.name], instanceIds: [...instanceIds, segment.id], container: false });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
await visit(tree.absoluteDirectory, [], []);
|
|
379
|
+
await this.discoverTreePaths(tree.id, entries);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
164
383
|
async initialize() {
|
|
165
384
|
await mkdir(this.config.stateDirectory, { recursive: true });
|
|
166
385
|
try {
|
|
@@ -204,13 +423,16 @@ export class SnapshotStore {
|
|
|
204
423
|
size: current?.size ?? 0,
|
|
205
424
|
mtimeMs: current?.mtimeMs ?? null,
|
|
206
425
|
splitRootId: root.splitRootId ?? null,
|
|
426
|
+
treeRootId: root.treeRootId ?? null,
|
|
427
|
+
container: root.container === true,
|
|
428
|
+
instanceIds: root.instanceIds ?? null,
|
|
207
429
|
};
|
|
208
430
|
}
|
|
209
431
|
|
|
210
432
|
getManifests() {
|
|
211
|
-
|
|
212
|
-
.sort((left, right) => left.studioPath.join(".").localeCompare(right.studioPath.join(".")))
|
|
213
|
-
|
|
433
|
+
this.manifestRoots ??= [...this.roots.values()]
|
|
434
|
+
.sort((left, right) => left.studioPath.join(".").localeCompare(right.studioPath.join(".")));
|
|
435
|
+
return this.manifestRoots.map((root) => this.getManifest(root.id));
|
|
214
436
|
}
|
|
215
437
|
|
|
216
438
|
getRoots() {
|
|
@@ -258,8 +480,9 @@ export class SnapshotStore {
|
|
|
258
480
|
return this.getManifest(id);
|
|
259
481
|
}
|
|
260
482
|
|
|
261
|
-
async delete(id, expectedHash) {
|
|
483
|
+
async delete(id, expectedHash, plainFolder = false) {
|
|
262
484
|
const root = this.getRoot(id);
|
|
485
|
+
if (root.container && !plainFolder) throw new Error("container snapshots must be restored or removed through the project configuration");
|
|
263
486
|
const current = await readSnapshot(root.absoluteFile);
|
|
264
487
|
if (!current || expectedHash !== current.hash) {
|
|
265
488
|
throw new ConflictError(this.getManifest(id));
|
|
@@ -270,7 +493,7 @@ export class SnapshotStore {
|
|
|
270
493
|
const previousRevision = this.state.roots[id]?.revision ?? 0;
|
|
271
494
|
this.state.roots[id] = {
|
|
272
495
|
exists: false,
|
|
273
|
-
deleted:
|
|
496
|
+
deleted: !root.container,
|
|
274
497
|
hash: null,
|
|
275
498
|
revision: previousRevision + 1,
|
|
276
499
|
size: 0,
|
|
@@ -279,6 +502,8 @@ export class SnapshotStore {
|
|
|
279
502
|
const deletedManifest = this.getManifest(id);
|
|
280
503
|
if (root.splitRootId) {
|
|
281
504
|
this.roots.delete(id);
|
|
505
|
+
this.manifestRoots = null;
|
|
506
|
+
this.rootsByFile.delete(root.absoluteFile.toLowerCase());
|
|
282
507
|
delete this.state.roots[id];
|
|
283
508
|
}
|
|
284
509
|
await this.persistState();
|
|
@@ -286,6 +511,7 @@ export class SnapshotStore {
|
|
|
286
511
|
}
|
|
287
512
|
|
|
288
513
|
async refresh(id, persist = true) {
|
|
514
|
+
if (this.boundaryChange || !this.roots.has(id)) return false;
|
|
289
515
|
const root = this.getRoot(id);
|
|
290
516
|
const snapshot = await readSnapshot(root.absoluteFile);
|
|
291
517
|
const previous = this.state.roots[id];
|