goldsync 0.1.11

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/server.mjs ADDED
@@ -0,0 +1,308 @@
1
+ import { watch } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import path from "node:path";
4
+ import { ConflictError } from "./store.mjs";
5
+
6
+ const MAX_SNAPSHOT_BYTES = 128 * 1024 * 1024;
7
+
8
+ function sendJson(response, status, value) {
9
+ const body = Buffer.from(JSON.stringify(value));
10
+ response.writeHead(status, {
11
+ "Content-Type": "application/json",
12
+ "Content-Length": body.length,
13
+ "Cache-Control": "no-store",
14
+ });
15
+ response.end(body);
16
+ }
17
+
18
+ async function readBody(request) {
19
+ const chunks = [];
20
+ let size = 0;
21
+ for await (const chunk of request) {
22
+ size += chunk.length;
23
+ if (size > MAX_SNAPSHOT_BYTES) {
24
+ const error = new Error("snapshot exceeds the 128 MiB limit");
25
+ error.statusCode = 413;
26
+ throw error;
27
+ }
28
+ chunks.push(chunk);
29
+ }
30
+ return Buffer.concat(chunks);
31
+ }
32
+
33
+ function requireStudioClient(request) {
34
+ if (request.headers.origin !== undefined) {
35
+ return false;
36
+ }
37
+ return request.headers["x-goldsync-client"] === "studio-plugin";
38
+ }
39
+
40
+ export class GoldSyncServer {
41
+ constructor(config, store, gitConflicts = null) {
42
+ this.config = config;
43
+ this.store = store;
44
+ this.gitConflicts = gitConflicts;
45
+ this.server = createServer((request, response) => {
46
+ this.handle(request, response).catch((error) => {
47
+ sendJson(response, error.statusCode ?? 500, { error: error.message });
48
+ });
49
+ });
50
+ this.watchers = [];
51
+ this.refreshTimers = new Map();
52
+ this.syncEnabled = true;
53
+ this.sessionToken = null;
54
+ }
55
+
56
+ setSyncEnabled(enabled) {
57
+ this.syncEnabled = enabled;
58
+ }
59
+
60
+ setSessionToken(token) {
61
+ this.sessionToken = token;
62
+ }
63
+
64
+ async start(options = {}) {
65
+ if (options.listen !== false) {
66
+ await new Promise((resolve, reject) => {
67
+ this.server.once("error", reject);
68
+ this.server.listen(this.config.port, this.config.host, () => {
69
+ this.server.off("error", reject);
70
+ resolve();
71
+ });
72
+ });
73
+ }
74
+
75
+ const watchedDirectories = new Map();
76
+ for (const root of this.config.roots) {
77
+ watchedDirectories.set(path.dirname(root.absoluteFile), false);
78
+ }
79
+ for (const splitRoot of this.config.splitRoots ?? []) {
80
+ watchedDirectories.set(splitRoot.absoluteDirectory, true);
81
+ }
82
+ for (const [directory, recursive] of watchedDirectories) {
83
+ const watcher = watch(directory, { recursive }, (_event, filename) => {
84
+ if (!filename) {
85
+ return;
86
+ }
87
+ const changedFile = path.join(directory, filename.toString()).toLowerCase();
88
+ let matched = false;
89
+ for (const root of this.store.getRoots()) {
90
+ if (root.absoluteFile.toLowerCase() === changedFile) {
91
+ matched = true;
92
+ this.scheduleRefresh(root.id);
93
+ }
94
+ }
95
+ if (!matched && recursive && path.extname(changedFile) === ".rbxm") {
96
+ this.store
97
+ .refreshSplitRoots()
98
+ .then(() => this.gitConflicts?.syncRoots?.(this.store.getRoots()))
99
+ .catch((error) => console.error(`[GoldSync] failed to discover split root for ${changedFile}:`, error));
100
+ }
101
+ });
102
+ watcher.on("error", (error) => console.error(`[GoldSync] watcher failed for ${directory}:`, error));
103
+ this.watchers.push(watcher);
104
+ }
105
+ }
106
+
107
+ async stop() {
108
+ for (const timer of this.refreshTimers.values()) {
109
+ clearTimeout(timer);
110
+ }
111
+ this.refreshTimers.clear();
112
+ for (const watcher of this.watchers) {
113
+ watcher.close();
114
+ }
115
+ this.watchers = [];
116
+ if (this.server.listening) {
117
+ await new Promise((resolve) => this.server.close(resolve));
118
+ }
119
+ }
120
+
121
+ get port() {
122
+ const address = this.server.address();
123
+ return typeof address === "object" && address !== null ? address.port : null;
124
+ }
125
+
126
+ scheduleRefresh(id) {
127
+ clearTimeout(this.refreshTimers.get(id));
128
+ this.refreshTimers.set(
129
+ id,
130
+ setTimeout(() => {
131
+ this.refreshTimers.delete(id);
132
+ this.store.refresh(id).catch((error) => console.error(`[GoldSync] failed to refresh ${id}:`, error));
133
+ }, 100),
134
+ );
135
+ }
136
+
137
+ async handle(request, response) {
138
+ if (!requireStudioClient(request)) {
139
+ sendJson(response, 403, { error: "request was not sent by the GoldSync Studio plugin" });
140
+ return;
141
+ }
142
+ if (!this.sessionToken || request.headers["x-goldsync-session"] !== this.sessionToken) {
143
+ sendJson(response, 409, { error: "this Studio place is not bound to the current Rojo session" });
144
+ return;
145
+ }
146
+ const url = new URL(request.url, `http://${this.config.host}:${this.config.port}`);
147
+ if (request.method === "GET" && url.pathname === "/v1/config") {
148
+ await this.store.refreshSplitRoots();
149
+ this.gitConflicts?.syncRoots?.(this.store.getRoots());
150
+ const conflicts = this.gitConflicts ? await this.gitConflicts.list() : new Map();
151
+ sendJson(response, 200, {
152
+ version: 1,
153
+ name: this.config.name,
154
+ projectId: this.config.projectId,
155
+ workspaceId: this.config.workspaceId,
156
+ maxPathDepth: this.config.maxPathDepth,
157
+ pollIntervalMs: this.config.pollIntervalMs,
158
+ debounceMs: this.config.debounceMs,
159
+ rojoConnected: this.syncEnabled,
160
+ splitRoots: (this.config.splitRoots ?? []).map((root) => ({
161
+ id: root.id,
162
+ studioPath: root.studioPath,
163
+ splitDepth: root.splitDepth,
164
+ })),
165
+ roots: this.store.getManifests().map((manifest) => {
166
+ const conflict = conflicts.get(manifest.id);
167
+ return {
168
+ ...manifest,
169
+ gitConflict: conflict !== undefined,
170
+ conflictToken: conflict?.token ?? null,
171
+ conflictStages: conflict?.stages ?? null,
172
+ };
173
+ }),
174
+ });
175
+ return;
176
+ }
177
+
178
+ const splitMatch = /^\/v1\/splits\/([a-z0-9-]+)\/discover$/.exec(url.pathname);
179
+ if (request.method === "POST" && splitMatch) {
180
+ const payload = JSON.parse((await readBody(request)).toString("utf8"));
181
+ if (!Array.isArray(payload.paths) || payload.paths.length > 10000) {
182
+ sendJson(response, 400, { error: "paths must be an array with at most 10000 entries" });
183
+ return;
184
+ }
185
+ await this.store.discoverSplitPaths(splitMatch[1], payload.paths);
186
+ this.gitConflicts?.syncRoots?.(this.store.getRoots());
187
+ sendJson(response, 200, { discovered: payload.paths.length });
188
+ return;
189
+ }
190
+
191
+ const conflictMatch = /^\/v1\/roots\/([a-z0-9-]+)\/conflict\/(base|ours|theirs)$/.exec(url.pathname);
192
+ if (request.method === "GET" && conflictMatch) {
193
+ if (!this.gitConflicts) {
194
+ sendJson(response, 404, { error: "Git conflict viewer is unavailable" });
195
+ return;
196
+ }
197
+ const bytes = await this.gitConflicts.readStage(conflictMatch[1], conflictMatch[2]);
198
+ if (!bytes) {
199
+ sendJson(response, 404, { error: "conflict stage does not exist" });
200
+ return;
201
+ }
202
+ response.writeHead(200, {
203
+ "Content-Type": "application/octet-stream",
204
+ "Content-Length": bytes.length,
205
+ "Cache-Control": "no-store",
206
+ });
207
+ response.end(bytes);
208
+ return;
209
+ }
210
+
211
+ const resolveMatch = /^\/v1\/roots\/([a-z0-9-]+)\/conflict\/resolve$/.exec(url.pathname);
212
+ if (request.method === "PUT" && resolveMatch) {
213
+ if (!this.gitConflicts) {
214
+ sendJson(response, 404, { error: "Git conflict viewer is unavailable" });
215
+ return;
216
+ }
217
+ const token = request.headers["x-goldsync-conflict-token"];
218
+ if (typeof token !== "string") {
219
+ sendJson(response, 428, { error: "X-GoldSync-Conflict-Token is required" });
220
+ return;
221
+ }
222
+ const manifest = await this.gitConflicts.resolve(resolveMatch[1], await readBody(request), token, this.store);
223
+ sendJson(response, 200, manifest);
224
+ return;
225
+ }
226
+
227
+ const match = /^\/v1\/roots\/([a-z0-9-]+)\/snapshot$/.exec(url.pathname);
228
+ if (!match) {
229
+ sendJson(response, 404, { error: "not found" });
230
+ return;
231
+ }
232
+
233
+ const id = match[1];
234
+ if (!this.store.roots.has(id)) {
235
+ sendJson(response, 404, { error: "unknown sync root" });
236
+ return;
237
+ }
238
+ if (this.gitConflicts && (await this.gitConflicts.list()).has(id)) {
239
+ sendJson(response, 409, { error: "resolve the Git conflict before synchronizing this root" });
240
+ return;
241
+ }
242
+ if (!this.syncEnabled) {
243
+ sendJson(response, 503, { error: "normal synchronization is paused until the configured Rojo project is served" });
244
+ return;
245
+ }
246
+ if (request.method === "DELETE") {
247
+ const expectedHashHeader = request.headers["if-match"];
248
+ if (typeof expectedHashHeader !== "string" || expectedHashHeader === "*") {
249
+ sendJson(response, 428, { error: "DELETE requires the last synchronized hash in If-Match" });
250
+ return;
251
+ }
252
+ const expectedHash = expectedHashHeader.replace(/^"|"$/g, "");
253
+ try {
254
+ sendJson(response, 200, await this.store.delete(id, expectedHash));
255
+ } catch (error) {
256
+ if (error instanceof ConflictError) {
257
+ sendJson(response, 409, { error: error.message, current: error.current });
258
+ return;
259
+ }
260
+ throw error;
261
+ }
262
+ return;
263
+ }
264
+ if (request.method === "GET") {
265
+ const snapshot = await this.store.get(id);
266
+ if (!snapshot) {
267
+ sendJson(response, 404, { error: "snapshot does not exist" });
268
+ return;
269
+ }
270
+ response.writeHead(200, {
271
+ "Content-Type": "application/octet-stream",
272
+ "Content-Length": snapshot.bytes.length,
273
+ ETag: `"${snapshot.hash}"`,
274
+ "Cache-Control": "no-store",
275
+ });
276
+ response.end(snapshot.bytes);
277
+ return;
278
+ }
279
+
280
+ if (request.method === "PUT") {
281
+ const expectedHashHeader = request.headers["if-match"];
282
+ if (typeof expectedHashHeader !== "string") {
283
+ sendJson(response, 428, { error: "If-Match is required" });
284
+ return;
285
+ }
286
+ const expectedHash = expectedHashHeader === "*" ? "*" : expectedHashHeader.replace(/^"|"$/g, "");
287
+ const bytes = await readBody(request);
288
+ if (bytes.length === 0) {
289
+ sendJson(response, 400, { error: "snapshot body is empty" });
290
+ return;
291
+ }
292
+
293
+ try {
294
+ const manifest = await this.store.put(id, bytes, expectedHash);
295
+ sendJson(response, 200, manifest);
296
+ } catch (error) {
297
+ if (error instanceof ConflictError) {
298
+ sendJson(response, 409, { error: error.message, current: error.current });
299
+ return;
300
+ }
301
+ throw error;
302
+ }
303
+ return;
304
+ }
305
+
306
+ sendJson(response, 405, { error: "method not allowed" });
307
+ }
308
+ }
@@ -0,0 +1,27 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function readSessionMarker(file) {
5
+ try {
6
+ const marker = JSON.parse(await readFile(file, "utf8"));
7
+ return typeof marker?.properties?.Value === "string" ? marker.properties.Value : null;
8
+ } catch (error) {
9
+ if (error.code === "ENOENT") {
10
+ return null;
11
+ }
12
+ throw error;
13
+ }
14
+ }
15
+
16
+ export async function writeSessionMarker(file, token) {
17
+ if ((await readSessionMarker(file)) === token) {
18
+ return;
19
+ }
20
+ await mkdir(path.dirname(file), { recursive: true });
21
+ const temporaryFile = `${file}.${process.pid}.tmp`;
22
+ await writeFile(
23
+ temporaryFile,
24
+ `${JSON.stringify({ className: "StringValue", properties: { Value: token } }, null, 2)}\n`,
25
+ );
26
+ await rename(temporaryFile, file);
27
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,337 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { copyFile, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ export class ConflictError extends Error {
6
+ constructor(current) {
7
+ super("snapshot changed since the client last synchronized");
8
+ this.name = "ConflictError";
9
+ this.current = current;
10
+ }
11
+ }
12
+
13
+ function hash(bytes) {
14
+ return createHash("sha256").update(bytes).digest("hex");
15
+ }
16
+
17
+ function encodePathSegment(segment) {
18
+ const encoded = segment.replace(/[%<>:"/\\|?*\u0000-\u001f]/g, (character) =>
19
+ [...Buffer.from(character)].map((byte) => `%${byte.toString(16).padStart(2, "0")}`).join(""),
20
+ );
21
+ return encoded.replace(/[. ]+$/g, (suffix) =>
22
+ [...Buffer.from(suffix)].map((byte) => `%${byte.toString(16).padStart(2, "0")}`).join(""),
23
+ );
24
+ }
25
+
26
+ function decodePathSegment(segment) {
27
+ return segment.replace(/(?:%[0-9a-f]{2})+/gi, (encoded) =>
28
+ Buffer.from(encoded.match(/[0-9a-f]{2}/gi).map((byte) => Number.parseInt(byte, 16))).toString("utf8"),
29
+ );
30
+ }
31
+
32
+ async function findSplitFiles(directory, depth, relative = []) {
33
+ let entries;
34
+ try {
35
+ entries = await readdir(directory, { withFileTypes: true });
36
+ } catch (error) {
37
+ if (error.code === "ENOENT") {
38
+ return [];
39
+ }
40
+ throw error;
41
+ }
42
+
43
+ const files = [];
44
+ for (const entry of entries) {
45
+ if (depth === 1 && entry.isFile() && path.extname(entry.name).toLowerCase() === ".rbxm") {
46
+ files.push([...relative, decodePathSegment(path.basename(entry.name, ".rbxm"))]);
47
+ } else if (depth > 1 && entry.isDirectory()) {
48
+ files.push(...(await findSplitFiles(path.join(directory, entry.name), depth - 1, [...relative, decodePathSegment(entry.name)])));
49
+ }
50
+ }
51
+ return files;
52
+ }
53
+
54
+ async function readSnapshot(file) {
55
+ try {
56
+ const bytes = await readFile(file);
57
+ const fileStat = await stat(file);
58
+ return {
59
+ bytes,
60
+ hash: hash(bytes),
61
+ size: bytes.length,
62
+ mtimeMs: fileStat.mtimeMs,
63
+ };
64
+ } catch (error) {
65
+ if (error.code === "ENOENT") {
66
+ return null;
67
+ }
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async function atomicWrite(file, bytes) {
73
+ await mkdir(path.dirname(file), { recursive: true });
74
+ const temporaryFile = `${file}.${process.pid}.${randomUUID()}.tmp`;
75
+ const handle = await open(temporaryFile, "wx");
76
+ try {
77
+ await handle.writeFile(bytes);
78
+ await handle.sync();
79
+ } finally {
80
+ await handle.close();
81
+ }
82
+
83
+ try {
84
+ await rename(temporaryFile, file);
85
+ } catch (error) {
86
+ await rm(temporaryFile, { force: true });
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ export class SnapshotStore {
92
+ constructor(config) {
93
+ this.config = config;
94
+ this.roots = new Map(config.roots.map((root) => [root.id, root]));
95
+ this.stateFile = path.join(config.stateDirectory, "state.json");
96
+ this.backupRoot = path.join(config.stateDirectory, "backups");
97
+ this.state = { version: 1, roots: {} };
98
+ }
99
+
100
+ makeSplitRoot(splitRoot, relativePath) {
101
+ if (
102
+ !Array.isArray(relativePath) ||
103
+ relativePath.length !== splitRoot.splitDepth ||
104
+ relativePath.some((segment) => typeof segment !== "string" || segment.length === 0)
105
+ ) {
106
+ throw new Error(`split root ${splitRoot.id} received an invalid relative path`);
107
+ }
108
+ const encodedPath = relativePath.map(encodePathSegment);
109
+ const relativeFile = path.join(splitRoot.directory, ...encodedPath) + ".rbxm";
110
+ const slug = relativePath
111
+ .join("-")
112
+ .toLowerCase()
113
+ .replace(/[^a-z0-9-]+/g, "-")
114
+ .replace(/^-+|-+$/g, "")
115
+ .slice(0, 40) || "root";
116
+ const suffix = createHash("sha256").update(JSON.stringify(relativePath)).digest("hex").slice(0, 8);
117
+ return {
118
+ id: `${splitRoot.id}-${slug}-${suffix}`,
119
+ studioPath: [...splitRoot.studioPath, ...relativePath],
120
+ file: relativeFile.replaceAll(path.sep, "/"),
121
+ absoluteFile: path.resolve(this.config.projectRoot, relativeFile),
122
+ splitRootId: splitRoot.id,
123
+ };
124
+ }
125
+
126
+ async addRoot(root) {
127
+ if (this.roots.has(root.id)) {
128
+ return false;
129
+ }
130
+ this.roots.set(root.id, root);
131
+ await mkdir(path.dirname(root.absoluteFile), { recursive: true });
132
+ await this.refresh(root.id, false);
133
+ return true;
134
+ }
135
+
136
+ async discoverSplitPaths(splitRootId, relativePaths) {
137
+ const splitRoot = (this.config.splitRoots ?? []).find((candidate) => candidate.id === splitRootId);
138
+ if (!splitRoot) {
139
+ throw new Error(`unknown split root: ${splitRootId}`);
140
+ }
141
+ let changed = false;
142
+ for (const relativePath of relativePaths) {
143
+ changed = (await this.addRoot(this.makeSplitRoot(splitRoot, relativePath))) || changed;
144
+ }
145
+ if (changed) {
146
+ await this.persistState();
147
+ }
148
+ return changed;
149
+ }
150
+
151
+ async refreshSplitRoots() {
152
+ for (const splitRoot of this.config.splitRoots ?? []) {
153
+ await mkdir(splitRoot.absoluteDirectory, { recursive: true });
154
+ const paths = await findSplitFiles(splitRoot.absoluteDirectory, splitRoot.splitDepth);
155
+ await this.discoverSplitPaths(splitRoot.id, paths);
156
+ }
157
+ }
158
+
159
+ async initialize() {
160
+ await mkdir(this.config.stateDirectory, { recursive: true });
161
+ try {
162
+ const state = JSON.parse(await readFile(this.stateFile, "utf8"));
163
+ if (state.version === 1 && typeof state.roots === "object") {
164
+ this.state = state;
165
+ }
166
+ } catch (error) {
167
+ if (error.code !== "ENOENT") {
168
+ throw new Error(`could not read ${this.stateFile}: ${error.message}`);
169
+ }
170
+ }
171
+
172
+ for (const root of this.config.roots) {
173
+ await mkdir(path.dirname(root.absoluteFile), { recursive: true });
174
+ await this.refresh(root.id, false);
175
+ }
176
+ await this.refreshSplitRoots();
177
+ await this.persistState();
178
+ }
179
+
180
+ getRoot(id) {
181
+ const root = this.roots.get(id);
182
+ if (!root) {
183
+ throw new Error(`unknown sync root: ${id}`);
184
+ }
185
+ return root;
186
+ }
187
+
188
+ getManifest(id) {
189
+ const root = this.getRoot(id);
190
+ const current = this.state.roots[id];
191
+ return {
192
+ id,
193
+ studioPath: root.studioPath,
194
+ file: root.file,
195
+ exists: current?.exists === true,
196
+ deleted: current?.deleted === true,
197
+ hash: current?.hash ?? null,
198
+ revision: current?.revision ?? 0,
199
+ size: current?.size ?? 0,
200
+ mtimeMs: current?.mtimeMs ?? null,
201
+ splitRootId: root.splitRootId ?? null,
202
+ };
203
+ }
204
+
205
+ getManifests() {
206
+ return [...this.roots.values()]
207
+ .sort((left, right) => left.studioPath.join(".").localeCompare(right.studioPath.join(".")))
208
+ .map((root) => this.getManifest(root.id));
209
+ }
210
+
211
+ getRoots() {
212
+ return [...this.roots.values()];
213
+ }
214
+
215
+ async get(id) {
216
+ const root = this.getRoot(id);
217
+ const snapshot = await readSnapshot(root.absoluteFile);
218
+ if (!snapshot) {
219
+ return null;
220
+ }
221
+ return snapshot;
222
+ }
223
+
224
+ async put(id, bytes, expectedHash) {
225
+ const root = this.getRoot(id);
226
+ const current = await readSnapshot(root.absoluteFile);
227
+ const currentHash = current?.hash ?? null;
228
+ const expectsMissing = expectedHash === "*";
229
+ if ((expectsMissing && current !== null) || (!expectsMissing && expectedHash !== currentHash)) {
230
+ throw new ConflictError(this.getManifest(id));
231
+ }
232
+
233
+ const nextHash = hash(bytes);
234
+ if (currentHash === nextHash) {
235
+ return this.getManifest(id);
236
+ }
237
+
238
+ if (current) {
239
+ await this.backup(id, current.hash);
240
+ }
241
+ await atomicWrite(root.absoluteFile, bytes);
242
+ const next = await readSnapshot(root.absoluteFile);
243
+ const previousRevision = this.state.roots[id]?.revision ?? 0;
244
+ this.state.roots[id] = {
245
+ exists: true,
246
+ deleted: false,
247
+ hash: next.hash,
248
+ revision: previousRevision + 1,
249
+ size: next.size,
250
+ mtimeMs: next.mtimeMs,
251
+ };
252
+ await this.persistState();
253
+ return this.getManifest(id);
254
+ }
255
+
256
+ async delete(id, expectedHash) {
257
+ const root = this.getRoot(id);
258
+ const current = await readSnapshot(root.absoluteFile);
259
+ if (!current || expectedHash !== current.hash) {
260
+ throw new ConflictError(this.getManifest(id));
261
+ }
262
+
263
+ await this.backup(id, current.hash);
264
+ await rm(root.absoluteFile);
265
+ const previousRevision = this.state.roots[id]?.revision ?? 0;
266
+ this.state.roots[id] = {
267
+ exists: false,
268
+ deleted: true,
269
+ hash: null,
270
+ revision: previousRevision + 1,
271
+ size: 0,
272
+ mtimeMs: null,
273
+ };
274
+ const deletedManifest = this.getManifest(id);
275
+ if (root.splitRootId) {
276
+ this.roots.delete(id);
277
+ delete this.state.roots[id];
278
+ }
279
+ await this.persistState();
280
+ return deletedManifest;
281
+ }
282
+
283
+ async refresh(id, persist = true) {
284
+ const root = this.getRoot(id);
285
+ const snapshot = await readSnapshot(root.absoluteFile);
286
+ const previous = this.state.roots[id];
287
+ const nextHash = snapshot?.hash ?? null;
288
+ if (previous === undefined && snapshot === null) {
289
+ this.state.roots[id] = {
290
+ exists: false,
291
+ deleted: false,
292
+ hash: null,
293
+ revision: 0,
294
+ size: 0,
295
+ mtimeMs: null,
296
+ };
297
+ if (persist) {
298
+ await this.persistState();
299
+ }
300
+ return false;
301
+ }
302
+ if ((previous?.hash ?? null) === nextHash && previous?.exists === (snapshot !== null)) {
303
+ return false;
304
+ }
305
+
306
+ this.state.roots[id] = {
307
+ exists: snapshot !== null,
308
+ deleted: false,
309
+ hash: nextHash,
310
+ revision: (previous?.revision ?? 0) + 1,
311
+ size: snapshot?.size ?? 0,
312
+ mtimeMs: snapshot?.mtimeMs ?? null,
313
+ };
314
+ if (persist) {
315
+ await this.persistState();
316
+ }
317
+ return true;
318
+ }
319
+
320
+ async backup(id, snapshotHash) {
321
+ const directory = path.join(this.backupRoot, id);
322
+ await mkdir(directory, { recursive: true });
323
+ const backupFile = path.join(directory, `${Date.now()}-${snapshotHash.slice(0, 12)}.rbxm`);
324
+ const root = this.getRoot(id);
325
+ await copyFile(root.absoluteFile, backupFile);
326
+
327
+ const backups = (await readdir(directory))
328
+ .filter((file) => file.endsWith(".rbxm"))
329
+ .sort()
330
+ .reverse();
331
+ await Promise.all(backups.slice(10).map((file) => rm(path.join(directory, file), { force: true })));
332
+ }
333
+
334
+ async persistState() {
335
+ await atomicWrite(this.stateFile, Buffer.from(`${JSON.stringify(this.state, null, 2)}\n`));
336
+ }
337
+ }