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