goldsync 0.1.15 → 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/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
- return [...this.roots.values()]
212
- .sort((left, right) => left.studioPath.join(".").localeCompare(right.studioPath.join(".")))
213
- .map((root) => this.getManifest(root.id));
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: true,
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];