@kjerneverk/riotplan-cloud 1.0.0-dev.0
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/dist/index.d.ts +179 -0
- package/dist/index.js +1113 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile, rename, mkdir, stat, rm, readdir } from "node:fs/promises";
|
|
3
|
+
import { join, resolve, dirname } from "node:path";
|
|
4
|
+
const SYNC_MANIFEST_VERSION = 1;
|
|
5
|
+
const SYNC_MANIFEST_FILE = ".riotplan-sync-manifest.json";
|
|
6
|
+
const REMOTE_INDEX_SCHEMA_VERSION = 1;
|
|
7
|
+
const REMOTE_INDEX_FILE = ".riotplan-sync-index-v1.json";
|
|
8
|
+
const REMOTE_INDEX_CACHE_FILE = ".riotplan-remote-index-cache-v1.json";
|
|
9
|
+
const REMOTE_INDEX_DOWNLOAD_TMP = ".riotplan-remote-index-download.tmp.json";
|
|
10
|
+
function buildSyncDiff(remoteObjects, manifestObjects, localFiles) {
|
|
11
|
+
const added = [];
|
|
12
|
+
const changed = [];
|
|
13
|
+
const unchanged = [];
|
|
14
|
+
const remotePaths = new Set(Object.keys(remoteObjects));
|
|
15
|
+
for (const relativePath of remotePaths) {
|
|
16
|
+
const remote = remoteObjects[relativePath];
|
|
17
|
+
const localExists = localFiles.has(relativePath);
|
|
18
|
+
const manifest = manifestObjects?.[relativePath];
|
|
19
|
+
if (!localExists) {
|
|
20
|
+
added.push(relativePath);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!manifest) {
|
|
24
|
+
changed.push(relativePath);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const byGeneration = manifest.generation && remote.generation && manifest.generation === remote.generation;
|
|
28
|
+
const byEtag = manifest.etag && remote.etag && manifest.etag === remote.etag;
|
|
29
|
+
const byMd5 = manifest.md5Hash && remote.md5Hash && manifest.md5Hash === remote.md5Hash;
|
|
30
|
+
if (byGeneration || byEtag || byMd5) {
|
|
31
|
+
unchanged.push(relativePath);
|
|
32
|
+
} else {
|
|
33
|
+
changed.push(relativePath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const deletedLocal = [];
|
|
37
|
+
for (const localRelative of localFiles) {
|
|
38
|
+
if (!remotePaths.has(localRelative)) {
|
|
39
|
+
deletedLocal.push(localRelative);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { added, changed, unchanged, deletedLocal };
|
|
43
|
+
}
|
|
44
|
+
function isRetryableError(error) {
|
|
45
|
+
if (!error || typeof error !== "object") return false;
|
|
46
|
+
const code = error.code;
|
|
47
|
+
const message = String(error.message || "").toLowerCase();
|
|
48
|
+
if (typeof code === "number" && [408, 429, 500, 502, 503, 504].includes(code)) return true;
|
|
49
|
+
if (typeof code === "string" && ["ETIMEDOUT", "ECONNRESET", "EAI_AGAIN"].includes(code)) return true;
|
|
50
|
+
return message.includes("timeout") || message.includes("temporar") || message.includes("rate");
|
|
51
|
+
}
|
|
52
|
+
async function withRetry(fn, retries = 2) {
|
|
53
|
+
let attempt = 0;
|
|
54
|
+
let lastError;
|
|
55
|
+
while (attempt <= retries) {
|
|
56
|
+
try {
|
|
57
|
+
return await fn();
|
|
58
|
+
} catch (error) {
|
|
59
|
+
lastError = error;
|
|
60
|
+
if (attempt >= retries || !isRetryableError(error)) {
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const backoffMs = 100 * Math.pow(2, attempt);
|
|
64
|
+
await new Promise((resolve2) => setTimeout(resolve2, backoffMs));
|
|
65
|
+
}
|
|
66
|
+
attempt += 1;
|
|
67
|
+
}
|
|
68
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
69
|
+
}
|
|
70
|
+
function normalizePrefix(prefix) {
|
|
71
|
+
if (!prefix) {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
return prefix.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
75
|
+
}
|
|
76
|
+
function toObjectName(prefix, relativePath) {
|
|
77
|
+
const normalizedRelative = relativePath.split("\\").join("/");
|
|
78
|
+
return prefix ? `${prefix}/${normalizedRelative}` : normalizedRelative;
|
|
79
|
+
}
|
|
80
|
+
function toRelativePath(prefix, objectName) {
|
|
81
|
+
if (!prefix) {
|
|
82
|
+
return objectName;
|
|
83
|
+
}
|
|
84
|
+
if (objectName.startsWith(`${prefix}/`)) {
|
|
85
|
+
return objectName.slice(prefix.length + 1);
|
|
86
|
+
}
|
|
87
|
+
return objectName;
|
|
88
|
+
}
|
|
89
|
+
function isInternalSyncControlFile(relativePath) {
|
|
90
|
+
const normalized = relativePath.split("\\").join("/");
|
|
91
|
+
const base = normalized.split("/").pop() || normalized;
|
|
92
|
+
return base === SYNC_MANIFEST_FILE || base === REMOTE_INDEX_FILE || base === REMOTE_INDEX_CACHE_FILE || base === REMOTE_INDEX_DOWNLOAD_TMP;
|
|
93
|
+
}
|
|
94
|
+
async function loadRemoteSyncIndexCache(localDirectory) {
|
|
95
|
+
const cachePath = join(localDirectory, REMOTE_INDEX_CACHE_FILE);
|
|
96
|
+
try {
|
|
97
|
+
const raw = await readFile(cachePath, "utf8");
|
|
98
|
+
const parsed = JSON.parse(raw);
|
|
99
|
+
if (!parsed || typeof parsed !== "object" || parsed.version !== REMOTE_INDEX_SCHEMA_VERSION || typeof parsed.indexObject !== "string" || !parsed.objects || typeof parsed.objects !== "object") {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async function saveRemoteSyncIndexCache(localDirectory, cache) {
|
|
108
|
+
const path = join(localDirectory, REMOTE_INDEX_CACHE_FILE);
|
|
109
|
+
const tempPath = `${path}.tmp`;
|
|
110
|
+
await writeFile(tempPath, `${JSON.stringify(cache, null, 2)}
|
|
111
|
+
`, "utf8");
|
|
112
|
+
await rename(tempPath, path);
|
|
113
|
+
}
|
|
114
|
+
function metadataStringValue(value) {
|
|
115
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
116
|
+
}
|
|
117
|
+
function metadataNumberValue(value) {
|
|
118
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "string" && value.trim()) {
|
|
122
|
+
const parsed = Number.parseInt(value, 10);
|
|
123
|
+
if (Number.isFinite(parsed)) {
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return void 0;
|
|
128
|
+
}
|
|
129
|
+
function parseRemoteMetadata(metadata) {
|
|
130
|
+
return {
|
|
131
|
+
md5Hash: metadataStringValue(metadata?.md5Hash),
|
|
132
|
+
generation: metadataStringValue(metadata?.generation),
|
|
133
|
+
etag: metadataStringValue(metadata?.etag),
|
|
134
|
+
size: metadataNumberValue(metadata?.size),
|
|
135
|
+
updatedAt: metadataStringValue(metadata?.updated)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function normalizeRemoteIndexObjects(objects) {
|
|
139
|
+
if (!objects || typeof objects !== "object") {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
const normalized = {};
|
|
143
|
+
for (const [rawPath, rawState] of Object.entries(objects)) {
|
|
144
|
+
const path = typeof rawPath === "string" ? rawPath : "";
|
|
145
|
+
if (!path || isInternalSyncControlFile(path)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const state = rawState;
|
|
149
|
+
normalized[path] = {
|
|
150
|
+
path,
|
|
151
|
+
generation: metadataStringValue(state?.generation),
|
|
152
|
+
etag: metadataStringValue(state?.etag),
|
|
153
|
+
md5Hash: metadataStringValue(state?.md5Hash),
|
|
154
|
+
size: metadataNumberValue(state?.size),
|
|
155
|
+
updatedAt: metadataStringValue(state?.updatedAt)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return normalized;
|
|
159
|
+
}
|
|
160
|
+
async function collectLocalFiles(rootDir) {
|
|
161
|
+
const entries = await readdir(rootDir, { withFileTypes: true }).catch(() => []);
|
|
162
|
+
const files = [];
|
|
163
|
+
for (const entry of entries) {
|
|
164
|
+
const fullPath = join(rootDir, entry.name);
|
|
165
|
+
if (entry.isDirectory()) {
|
|
166
|
+
const nested = await collectLocalFiles(fullPath);
|
|
167
|
+
for (const child of nested) {
|
|
168
|
+
files.push(join(entry.name, child));
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
files.push(entry.name);
|
|
173
|
+
}
|
|
174
|
+
return files;
|
|
175
|
+
}
|
|
176
|
+
async function fileMd5Base64(path) {
|
|
177
|
+
const buffer = await readFile(path);
|
|
178
|
+
return createHash("md5").update(buffer).digest("base64");
|
|
179
|
+
}
|
|
180
|
+
async function fileExists(path) {
|
|
181
|
+
try {
|
|
182
|
+
await stat(path);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function resolveRemoteMd5(file) {
|
|
189
|
+
if (file.metadata?.md5Hash) {
|
|
190
|
+
return file.metadata.md5Hash;
|
|
191
|
+
}
|
|
192
|
+
if (typeof file.getMetadata === "function") {
|
|
193
|
+
try {
|
|
194
|
+
const [metadata] = await file.getMetadata();
|
|
195
|
+
const md5Hash = typeof metadata?.md5Hash === "string" ? metadata.md5Hash : void 0;
|
|
196
|
+
if (md5Hash) {
|
|
197
|
+
file.metadata = { ...file.metadata || {}, md5Hash };
|
|
198
|
+
}
|
|
199
|
+
return md5Hash;
|
|
200
|
+
} catch {
|
|
201
|
+
return void 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return void 0;
|
|
205
|
+
}
|
|
206
|
+
async function resolveRemoteMetadata(file) {
|
|
207
|
+
const fromInline = parseRemoteMetadata(file.metadata);
|
|
208
|
+
if (fromInline.md5Hash || fromInline.generation || fromInline.etag || fromInline.size !== void 0 || fromInline.updatedAt) {
|
|
209
|
+
return fromInline;
|
|
210
|
+
}
|
|
211
|
+
if (typeof file.getMetadata !== "function") {
|
|
212
|
+
return fromInline;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const [metadata] = await file.getMetadata();
|
|
216
|
+
const parsed = parseRemoteMetadata(metadata);
|
|
217
|
+
const md5Hash = parsed.md5Hash || file.metadata?.md5Hash;
|
|
218
|
+
const generation = parsed.generation;
|
|
219
|
+
const etag = parsed.etag;
|
|
220
|
+
const size = parsed.size;
|
|
221
|
+
const updatedAt = parsed.updatedAt;
|
|
222
|
+
if (md5Hash) {
|
|
223
|
+
file.metadata = { ...file.metadata || {}, md5Hash };
|
|
224
|
+
}
|
|
225
|
+
return { md5Hash, generation, etag, size, updatedAt };
|
|
226
|
+
} catch {
|
|
227
|
+
return {
|
|
228
|
+
md5Hash: file.metadata?.md5Hash
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function loadRemoteSyncIndex(bucket, prefix, localDirectory, onDebugEvent) {
|
|
233
|
+
const indexObject = toObjectName(prefix, REMOTE_INDEX_FILE);
|
|
234
|
+
const remoteIndexFile = bucket.file(indexObject);
|
|
235
|
+
if (!remoteIndexFile || typeof remoteIndexFile.getMetadata !== "function") {
|
|
236
|
+
return { objects: null, usedCachedIndex: false };
|
|
237
|
+
}
|
|
238
|
+
let metadata;
|
|
239
|
+
try {
|
|
240
|
+
const [resolved] = await withRetry(() => remoteIndexFile.getMetadata());
|
|
241
|
+
metadata = resolved;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error?.code === 404) {
|
|
244
|
+
return { objects: null, usedCachedIndex: false };
|
|
245
|
+
}
|
|
246
|
+
onDebugEvent?.("sync_down.index.metadata_failed", {
|
|
247
|
+
indexObject,
|
|
248
|
+
error: error instanceof Error ? error.message : String(error)
|
|
249
|
+
});
|
|
250
|
+
return { objects: null, usedCachedIndex: false };
|
|
251
|
+
}
|
|
252
|
+
const indexEtag = metadataStringValue(metadata?.etag);
|
|
253
|
+
const indexGeneration = metadataStringValue(metadata?.generation);
|
|
254
|
+
const cache = await loadRemoteSyncIndexCache(localDirectory);
|
|
255
|
+
if (cache && cache.indexObject === indexObject && (indexEtag && cache.etag === indexEtag || indexGeneration && cache.generation === indexGeneration)) {
|
|
256
|
+
onDebugEvent?.("sync_down.index.cache_hit", {
|
|
257
|
+
indexObject,
|
|
258
|
+
objectCount: Object.keys(cache.objects || {}).length
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
objects: normalizeRemoteIndexObjects(cache.objects),
|
|
262
|
+
indexEtag,
|
|
263
|
+
indexGeneration,
|
|
264
|
+
usedCachedIndex: true
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const downloadPath = join(localDirectory, REMOTE_INDEX_DOWNLOAD_TMP);
|
|
268
|
+
try {
|
|
269
|
+
await withRetry(() => remoteIndexFile.download({ destination: downloadPath }));
|
|
270
|
+
const raw = await readFile(downloadPath, "utf8");
|
|
271
|
+
const parsed = JSON.parse(raw);
|
|
272
|
+
if (parsed.version !== REMOTE_INDEX_SCHEMA_VERSION || !parsed.objects || typeof parsed.objects !== "object") {
|
|
273
|
+
onDebugEvent?.("sync_down.index.invalid_schema", {
|
|
274
|
+
indexObject,
|
|
275
|
+
version: parsed.version ?? null
|
|
276
|
+
});
|
|
277
|
+
return { objects: null, indexEtag, indexGeneration, usedCachedIndex: false };
|
|
278
|
+
}
|
|
279
|
+
const normalizedObjects = normalizeRemoteIndexObjects(parsed.objects);
|
|
280
|
+
await saveRemoteSyncIndexCache(localDirectory, {
|
|
281
|
+
version: REMOTE_INDEX_SCHEMA_VERSION,
|
|
282
|
+
indexObject,
|
|
283
|
+
etag: indexEtag,
|
|
284
|
+
generation: indexGeneration,
|
|
285
|
+
updatedAt: metadataStringValue(metadata?.updated),
|
|
286
|
+
objects: normalizedObjects
|
|
287
|
+
});
|
|
288
|
+
onDebugEvent?.("sync_down.index.downloaded", {
|
|
289
|
+
indexObject,
|
|
290
|
+
objectCount: Object.keys(normalizedObjects).length
|
|
291
|
+
});
|
|
292
|
+
return {
|
|
293
|
+
objects: normalizedObjects,
|
|
294
|
+
indexEtag,
|
|
295
|
+
indexGeneration,
|
|
296
|
+
usedCachedIndex: false
|
|
297
|
+
};
|
|
298
|
+
} catch (error) {
|
|
299
|
+
onDebugEvent?.("sync_down.index.download_failed", {
|
|
300
|
+
indexObject,
|
|
301
|
+
error: error instanceof Error ? error.message : String(error)
|
|
302
|
+
});
|
|
303
|
+
return { objects: null, indexEtag, indexGeneration, usedCachedIndex: false };
|
|
304
|
+
} finally {
|
|
305
|
+
await rm(downloadPath, { force: true }).catch(() => void 0);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function writeRemoteSyncIndex(bucket, prefix, localDirectory, objects, onDebugEvent) {
|
|
309
|
+
const indexObject = toObjectName(prefix, REMOTE_INDEX_FILE);
|
|
310
|
+
const tempPath = join(localDirectory, `${REMOTE_INDEX_FILE}.tmp`);
|
|
311
|
+
const payload = {
|
|
312
|
+
version: REMOTE_INDEX_SCHEMA_VERSION,
|
|
313
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
314
|
+
objects
|
|
315
|
+
};
|
|
316
|
+
try {
|
|
317
|
+
await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}
|
|
318
|
+
`, "utf8");
|
|
319
|
+
await withRetry(() => bucket.upload(tempPath, { destination: indexObject }));
|
|
320
|
+
onDebugEvent?.("sync_up.index.written", {
|
|
321
|
+
indexObject,
|
|
322
|
+
objectCount: Object.keys(objects).length
|
|
323
|
+
});
|
|
324
|
+
} finally {
|
|
325
|
+
await rm(tempPath, { force: true }).catch(() => void 0);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function loadSyncManifest(localDirectory) {
|
|
329
|
+
const manifestPath = join(localDirectory, SYNC_MANIFEST_FILE);
|
|
330
|
+
try {
|
|
331
|
+
const raw = await readFile(manifestPath, "utf8");
|
|
332
|
+
const parsed = JSON.parse(raw);
|
|
333
|
+
if (!parsed || typeof parsed !== "object" || parsed.version !== SYNC_MANIFEST_VERSION || !parsed.objects) {
|
|
334
|
+
return { manifest: null, invalidated: true };
|
|
335
|
+
}
|
|
336
|
+
return { manifest: parsed, invalidated: false };
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error?.code === "ENOENT") {
|
|
339
|
+
return { manifest: null, invalidated: false };
|
|
340
|
+
}
|
|
341
|
+
return { manifest: null, invalidated: true };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
async function writeSyncManifest(localDirectory, objects) {
|
|
345
|
+
const manifestPath = join(localDirectory, SYNC_MANIFEST_FILE);
|
|
346
|
+
const tempManifestPath = `${manifestPath}.tmp`;
|
|
347
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
348
|
+
const payload = {
|
|
349
|
+
version: SYNC_MANIFEST_VERSION,
|
|
350
|
+
createdAt: now,
|
|
351
|
+
updatedAt: now,
|
|
352
|
+
objects
|
|
353
|
+
};
|
|
354
|
+
await writeFile(tempManifestPath, `${JSON.stringify(payload, null, 2)}
|
|
355
|
+
`, "utf8");
|
|
356
|
+
await rename(tempManifestPath, manifestPath);
|
|
357
|
+
}
|
|
358
|
+
async function createStorageClient(auth) {
|
|
359
|
+
let credentials;
|
|
360
|
+
if (auth.credentialsJson) {
|
|
361
|
+
try {
|
|
362
|
+
credentials = JSON.parse(auth.credentialsJson);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
throw new Error(`Invalid cloud.credentialsJson JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const moduleName = "@google-cloud/storage";
|
|
368
|
+
const storageModule = await import(moduleName);
|
|
369
|
+
return new storageModule.Storage({
|
|
370
|
+
projectId: auth.projectId,
|
|
371
|
+
keyFilename: auth.keyFilename,
|
|
372
|
+
credentials
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
class GcsMirror {
|
|
376
|
+
auth;
|
|
377
|
+
bucketName;
|
|
378
|
+
prefix;
|
|
379
|
+
localDirectory;
|
|
380
|
+
includeFile;
|
|
381
|
+
incrementalSyncEnabled;
|
|
382
|
+
onDebugEvent;
|
|
383
|
+
constructor(options) {
|
|
384
|
+
this.auth = options.auth;
|
|
385
|
+
this.bucketName = options.location.bucket;
|
|
386
|
+
this.prefix = normalizePrefix(options.location.prefix);
|
|
387
|
+
this.localDirectory = resolve(options.localDirectory);
|
|
388
|
+
this.includeFile = options.includeFile || (() => true);
|
|
389
|
+
this.incrementalSyncEnabled = options.incrementalSyncEnabled !== false;
|
|
390
|
+
this.onDebugEvent = options.onDebugEvent;
|
|
391
|
+
}
|
|
392
|
+
async syncDown() {
|
|
393
|
+
const startedAt = Date.now();
|
|
394
|
+
this.onDebugEvent?.("sync_down.start", {
|
|
395
|
+
bucket: this.bucketName,
|
|
396
|
+
prefix: this.prefix,
|
|
397
|
+
localDirectory: this.localDirectory
|
|
398
|
+
});
|
|
399
|
+
const mkdirStartedAt = Date.now();
|
|
400
|
+
await mkdir(this.localDirectory, { recursive: true });
|
|
401
|
+
const mkdirMs = Date.now() - mkdirStartedAt;
|
|
402
|
+
this.onDebugEvent?.("sync_down.phase.mkdir", { elapsedMs: mkdirMs });
|
|
403
|
+
const clientStartedAt = Date.now();
|
|
404
|
+
const storage = await createStorageClient(this.auth);
|
|
405
|
+
const createClientMs = Date.now() - clientStartedAt;
|
|
406
|
+
this.onDebugEvent?.("sync_down.phase.create_client", { elapsedMs: createClientMs });
|
|
407
|
+
const bucket = storage.bucket(this.bucketName);
|
|
408
|
+
const listRemoteStartedAt = Date.now();
|
|
409
|
+
const remoteIndex = await loadRemoteSyncIndex(bucket, this.prefix, this.localDirectory, this.onDebugEvent);
|
|
410
|
+
let files = [];
|
|
411
|
+
let usedRemoteIndex = false;
|
|
412
|
+
if (remoteIndex.objects) {
|
|
413
|
+
usedRemoteIndex = true;
|
|
414
|
+
} else {
|
|
415
|
+
[files] = await withRetry(() => bucket.getFiles({ prefix: this.prefix || void 0 }));
|
|
416
|
+
}
|
|
417
|
+
const listRemoteMs = Date.now() - listRemoteStartedAt;
|
|
418
|
+
this.onDebugEvent?.("sync_down.phase.list_remote", {
|
|
419
|
+
elapsedMs: listRemoteMs,
|
|
420
|
+
listedCount: usedRemoteIndex ? Object.keys(remoteIndex.objects || {}).length : files.length,
|
|
421
|
+
source: usedRemoteIndex ? "gcs-index" : "bucket-list",
|
|
422
|
+
usedCachedIndex: remoteIndex.usedCachedIndex
|
|
423
|
+
});
|
|
424
|
+
const { manifest, invalidated } = this.incrementalSyncEnabled ? await loadSyncManifest(this.localDirectory) : { manifest: null, invalidated: false };
|
|
425
|
+
if (invalidated) {
|
|
426
|
+
this.onDebugEvent?.("sync_down.manifest.invalidated", {
|
|
427
|
+
localDirectory: this.localDirectory,
|
|
428
|
+
reason: "unsupported_or_corrupt_manifest"
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const localFilesAtStart = await collectLocalFiles(this.localDirectory);
|
|
432
|
+
const localTrackedAtStart = new Set(
|
|
433
|
+
localFilesAtStart.filter((relativePath) => this.includeFile(relativePath))
|
|
434
|
+
);
|
|
435
|
+
const remoteStateByPath = {};
|
|
436
|
+
const remoteFileByPath = /* @__PURE__ */ new Map();
|
|
437
|
+
if (usedRemoteIndex && remoteIndex.objects) {
|
|
438
|
+
for (const [relativePath, objectState] of Object.entries(remoteIndex.objects)) {
|
|
439
|
+
if (!relativePath || isInternalSyncControlFile(relativePath) || !this.includeFile(relativePath)) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
remoteStateByPath[relativePath] = objectState;
|
|
443
|
+
remoteFileByPath.set(relativePath, bucket.file(toObjectName(this.prefix, relativePath)));
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
for (const file of files) {
|
|
447
|
+
const objectName = file.name;
|
|
448
|
+
if (objectName.endsWith("/")) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const relativePath = toRelativePath(this.prefix, objectName);
|
|
452
|
+
if (!relativePath || isInternalSyncControlFile(relativePath) || !this.includeFile(relativePath)) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
remoteFileByPath.set(relativePath, file);
|
|
456
|
+
const remoteMeta = await resolveRemoteMetadata(file);
|
|
457
|
+
remoteStateByPath[relativePath] = {
|
|
458
|
+
path: relativePath,
|
|
459
|
+
generation: remoteMeta.generation,
|
|
460
|
+
etag: remoteMeta.etag,
|
|
461
|
+
md5Hash: remoteMeta.md5Hash,
|
|
462
|
+
size: remoteMeta.size,
|
|
463
|
+
updatedAt: remoteMeta.updatedAt
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const remoteSet = new Set(Object.keys(remoteStateByPath));
|
|
468
|
+
const remoteIncludedCount = remoteSet.size;
|
|
469
|
+
let diff;
|
|
470
|
+
if (!this.incrementalSyncEnabled) {
|
|
471
|
+
diff = {
|
|
472
|
+
added: [...remoteSet],
|
|
473
|
+
changed: [],
|
|
474
|
+
unchanged: [],
|
|
475
|
+
deletedLocal: [...localTrackedAtStart].filter((path) => !remoteSet.has(path))
|
|
476
|
+
};
|
|
477
|
+
} else {
|
|
478
|
+
try {
|
|
479
|
+
diff = buildSyncDiff(remoteStateByPath, manifest?.objects, localTrackedAtStart);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
this.onDebugEvent?.("sync_down.diff_recovery", {
|
|
482
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
483
|
+
strategy: "full_resync"
|
|
484
|
+
});
|
|
485
|
+
diff = {
|
|
486
|
+
added: [],
|
|
487
|
+
changed: [...remoteSet],
|
|
488
|
+
unchanged: [],
|
|
489
|
+
deletedLocal: [...localTrackedAtStart].filter((path) => !remoteSet.has(path))
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
let changedCount = diff.added.length + diff.changed.length;
|
|
494
|
+
let skippedUnchangedCount = diff.unchanged.length;
|
|
495
|
+
let downloadedCount = 0;
|
|
496
|
+
let downloadedBytes = 0;
|
|
497
|
+
const manifestObjects = {};
|
|
498
|
+
const downloadStartedAt = Date.now();
|
|
499
|
+
const downloadTargets = [...diff.added, ...diff.changed];
|
|
500
|
+
for (const relativePath of downloadTargets) {
|
|
501
|
+
const file = remoteFileByPath.get(relativePath);
|
|
502
|
+
if (!file) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const localPath = join(this.localDirectory, relativePath);
|
|
506
|
+
let shouldDownload = true;
|
|
507
|
+
const remoteMeta = remoteStateByPath[relativePath];
|
|
508
|
+
const existingManifestEntry = manifest?.objects?.[relativePath];
|
|
509
|
+
const existsLocally = await fileExists(localPath);
|
|
510
|
+
if (this.incrementalSyncEnabled && existsLocally && existingManifestEntry) {
|
|
511
|
+
const unchangedByManifest = existingManifestEntry.generation && remoteMeta?.generation && existingManifestEntry.generation === remoteMeta.generation;
|
|
512
|
+
if (unchangedByManifest) {
|
|
513
|
+
shouldDownload = false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (this.incrementalSyncEnabled && existsLocally && shouldDownload) {
|
|
517
|
+
const remoteMd5 = remoteMeta?.md5Hash || await resolveRemoteMd5(file);
|
|
518
|
+
if (remoteMd5) {
|
|
519
|
+
const localMd5 = await fileMd5Base64(localPath);
|
|
520
|
+
shouldDownload = localMd5 !== remoteMd5;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (!shouldDownload) {
|
|
524
|
+
skippedUnchangedCount += 1;
|
|
525
|
+
changedCount -= 1;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
await mkdir(dirname(localPath), { recursive: true });
|
|
529
|
+
const downloadFileStartedAt = Date.now();
|
|
530
|
+
await withRetry(() => file.download({ destination: localPath }));
|
|
531
|
+
const fileElapsedMs = Date.now() - downloadFileStartedAt;
|
|
532
|
+
downloadedCount += 1;
|
|
533
|
+
try {
|
|
534
|
+
downloadedBytes += (await stat(localPath)).size;
|
|
535
|
+
} catch {
|
|
536
|
+
}
|
|
537
|
+
if (fileElapsedMs >= 250) {
|
|
538
|
+
this.onDebugEvent?.("sync_down.file_download", {
|
|
539
|
+
path: relativePath,
|
|
540
|
+
elapsedMs: fileElapsedMs
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
manifestObjects[relativePath] = {
|
|
544
|
+
path: relativePath,
|
|
545
|
+
generation: remoteMeta?.generation,
|
|
546
|
+
etag: remoteMeta?.etag,
|
|
547
|
+
md5Hash: remoteMeta?.md5Hash,
|
|
548
|
+
size: remoteMeta?.size,
|
|
549
|
+
updatedAt: remoteMeta?.updatedAt,
|
|
550
|
+
localPath: relativePath,
|
|
551
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
for (const relativePath of remoteSet) {
|
|
555
|
+
if (!manifestObjects[relativePath] && manifest?.objects?.[relativePath]) {
|
|
556
|
+
manifestObjects[relativePath] = {
|
|
557
|
+
...manifest.objects[relativePath],
|
|
558
|
+
lastSyncedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const downloadMs = Date.now() - downloadStartedAt;
|
|
563
|
+
this.onDebugEvent?.("sync_down.phase.download", {
|
|
564
|
+
elapsedMs: downloadMs,
|
|
565
|
+
downloadedCount,
|
|
566
|
+
includedCount: remoteIncludedCount,
|
|
567
|
+
changedCount,
|
|
568
|
+
skippedUnchangedCount,
|
|
569
|
+
downloadedBytes
|
|
570
|
+
});
|
|
571
|
+
const listLocalStartedAt = Date.now();
|
|
572
|
+
const localFiles = await collectLocalFiles(this.localDirectory);
|
|
573
|
+
const listLocalMs = Date.now() - listLocalStartedAt;
|
|
574
|
+
this.onDebugEvent?.("sync_down.phase.list_local", { elapsedMs: listLocalMs, scannedCount: localFiles.length });
|
|
575
|
+
const cleanupStartedAt = Date.now();
|
|
576
|
+
let removedCount = 0;
|
|
577
|
+
this.onDebugEvent?.("sync_down.gc.start", {
|
|
578
|
+
localDirectory: this.localDirectory,
|
|
579
|
+
deletedLocalCandidates: diff.deletedLocal.length
|
|
580
|
+
});
|
|
581
|
+
for (const localRelative of diff.deletedLocal) {
|
|
582
|
+
if (!remoteSet.has(localRelative)) {
|
|
583
|
+
await rm(join(this.localDirectory, localRelative), { force: true });
|
|
584
|
+
removedCount += 1;
|
|
585
|
+
this.onDebugEvent?.("sync_down.gc.remove_local", {
|
|
586
|
+
path: localRelative
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const cleanupMs = Date.now() - cleanupStartedAt;
|
|
591
|
+
this.onDebugEvent?.("sync_down.gc.complete", {
|
|
592
|
+
removedCount,
|
|
593
|
+
elapsedMs: cleanupMs
|
|
594
|
+
});
|
|
595
|
+
if (this.incrementalSyncEnabled) {
|
|
596
|
+
const manifestWriteStartedAt = Date.now();
|
|
597
|
+
await writeSyncManifest(this.localDirectory, manifestObjects);
|
|
598
|
+
this.onDebugEvent?.("sync_down.manifest.write_complete", {
|
|
599
|
+
objectCount: Object.keys(manifestObjects).length,
|
|
600
|
+
elapsedMs: Date.now() - manifestWriteStartedAt
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
const elapsedMs = Date.now() - startedAt;
|
|
604
|
+
const stats = {
|
|
605
|
+
bucket: this.bucketName,
|
|
606
|
+
prefix: this.prefix,
|
|
607
|
+
localDirectory: this.localDirectory,
|
|
608
|
+
remoteListedCount: usedRemoteIndex ? remoteIncludedCount : files.length,
|
|
609
|
+
remoteIncludedCount,
|
|
610
|
+
changedCount,
|
|
611
|
+
skippedUnchangedCount,
|
|
612
|
+
downloadedCount,
|
|
613
|
+
downloadedBytes,
|
|
614
|
+
localScannedCount: localFiles.length,
|
|
615
|
+
removedCount,
|
|
616
|
+
elapsedMs,
|
|
617
|
+
phases: {
|
|
618
|
+
mkdirMs,
|
|
619
|
+
createClientMs,
|
|
620
|
+
listRemoteMs,
|
|
621
|
+
downloadMs,
|
|
622
|
+
listLocalMs,
|
|
623
|
+
cleanupMs
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
this.onDebugEvent?.("sync_down.complete", stats);
|
|
627
|
+
return stats;
|
|
628
|
+
}
|
|
629
|
+
async syncUp() {
|
|
630
|
+
const startedAt = Date.now();
|
|
631
|
+
this.onDebugEvent?.("sync_up.start", {
|
|
632
|
+
bucket: this.bucketName,
|
|
633
|
+
prefix: this.prefix,
|
|
634
|
+
localDirectory: this.localDirectory
|
|
635
|
+
});
|
|
636
|
+
const mkdirStartedAt = Date.now();
|
|
637
|
+
await mkdir(this.localDirectory, { recursive: true });
|
|
638
|
+
const mkdirMs = Date.now() - mkdirStartedAt;
|
|
639
|
+
const clientStartedAt = Date.now();
|
|
640
|
+
const storage = await createStorageClient(this.auth);
|
|
641
|
+
const createClientMs = Date.now() - clientStartedAt;
|
|
642
|
+
const bucket = storage.bucket(this.bucketName);
|
|
643
|
+
const listRemoteStartedAt = Date.now();
|
|
644
|
+
const [remoteFiles] = await withRetry(() => bucket.getFiles({ prefix: this.prefix || void 0 }));
|
|
645
|
+
const listRemoteMs = Date.now() - listRemoteStartedAt;
|
|
646
|
+
const remoteByRelative = /* @__PURE__ */ new Map();
|
|
647
|
+
for (const remote of remoteFiles) {
|
|
648
|
+
if (remote.name.endsWith("/")) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const relativePath = toRelativePath(this.prefix, remote.name);
|
|
652
|
+
if (!relativePath || isInternalSyncControlFile(relativePath) || !this.includeFile(relativePath)) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
remoteByRelative.set(relativePath, remote);
|
|
656
|
+
}
|
|
657
|
+
const listLocalStartedAt = Date.now();
|
|
658
|
+
const localFiles = await collectLocalFiles(this.localDirectory);
|
|
659
|
+
const listLocalMs = Date.now() - listLocalStartedAt;
|
|
660
|
+
const localSet = /* @__PURE__ */ new Set();
|
|
661
|
+
let localIncludedCount = 0;
|
|
662
|
+
let uploadedCount = 0;
|
|
663
|
+
const uploadStartedAt = Date.now();
|
|
664
|
+
for (const localRelative of localFiles) {
|
|
665
|
+
if (isInternalSyncControlFile(localRelative) || !this.includeFile(localRelative)) {
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
localIncludedCount += 1;
|
|
669
|
+
localSet.add(localRelative);
|
|
670
|
+
const localPath = join(this.localDirectory, localRelative);
|
|
671
|
+
const remoteFile = remoteByRelative.get(localRelative);
|
|
672
|
+
let shouldUpload = true;
|
|
673
|
+
if (remoteFile) {
|
|
674
|
+
const remoteMd5 = await resolveRemoteMd5(remoteFile);
|
|
675
|
+
if (remoteMd5) {
|
|
676
|
+
const localMd5 = await fileMd5Base64(localPath);
|
|
677
|
+
shouldUpload = localMd5 !== remoteMd5;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (!shouldUpload) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const objectName = toObjectName(this.prefix, localRelative);
|
|
684
|
+
const uploadFileStartedAt = Date.now();
|
|
685
|
+
await withRetry(
|
|
686
|
+
() => bucket.upload(localPath, {
|
|
687
|
+
destination: objectName
|
|
688
|
+
})
|
|
689
|
+
);
|
|
690
|
+
const fileElapsedMs = Date.now() - uploadFileStartedAt;
|
|
691
|
+
uploadedCount += 1;
|
|
692
|
+
if (fileElapsedMs >= 250) {
|
|
693
|
+
this.onDebugEvent?.("sync_up.file_upload", {
|
|
694
|
+
path: localRelative,
|
|
695
|
+
elapsedMs: fileElapsedMs
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const uploadMs = Date.now() - uploadStartedAt;
|
|
700
|
+
let removedRemoteCount = 0;
|
|
701
|
+
const cleanupStartedAt = Date.now();
|
|
702
|
+
this.onDebugEvent?.("sync_up.gc.start", {
|
|
703
|
+
bucket: this.bucketName,
|
|
704
|
+
prefix: this.prefix,
|
|
705
|
+
remoteCandidates: remoteFiles.length
|
|
706
|
+
});
|
|
707
|
+
for (const remote of remoteFiles) {
|
|
708
|
+
if (remote.name.endsWith("/")) {
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
const relativePath = toRelativePath(this.prefix, remote.name);
|
|
712
|
+
if (!relativePath || isInternalSyncControlFile(relativePath) || !this.includeFile(relativePath)) {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (!localSet.has(relativePath)) {
|
|
716
|
+
await withRetry(() => remote.delete({ ignoreNotFound: true }));
|
|
717
|
+
removedRemoteCount += 1;
|
|
718
|
+
this.onDebugEvent?.("sync_up.gc.remove_remote", {
|
|
719
|
+
path: relativePath
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const cleanupMs = Date.now() - cleanupStartedAt;
|
|
724
|
+
this.onDebugEvent?.("sync_up.gc.complete", {
|
|
725
|
+
removedRemoteCount,
|
|
726
|
+
elapsedMs: cleanupMs
|
|
727
|
+
});
|
|
728
|
+
const indexStartedAt = Date.now();
|
|
729
|
+
const indexObjects = {};
|
|
730
|
+
for (const localRelative of localSet) {
|
|
731
|
+
const localPath = join(this.localDirectory, localRelative);
|
|
732
|
+
const localStats = await stat(localPath).catch(() => null);
|
|
733
|
+
const md5Hash = await fileMd5Base64(localPath).catch(() => void 0);
|
|
734
|
+
indexObjects[localRelative] = {
|
|
735
|
+
path: localRelative,
|
|
736
|
+
md5Hash,
|
|
737
|
+
size: localStats?.size,
|
|
738
|
+
updatedAt: localStats?.mtime ? localStats.mtime.toISOString() : void 0
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
await writeRemoteSyncIndex(bucket, this.prefix, this.localDirectory, indexObjects, this.onDebugEvent);
|
|
742
|
+
this.onDebugEvent?.("sync_up.phase.write_index", {
|
|
743
|
+
elapsedMs: Date.now() - indexStartedAt,
|
|
744
|
+
objectCount: Object.keys(indexObjects).length
|
|
745
|
+
});
|
|
746
|
+
const elapsedMs = Date.now() - startedAt;
|
|
747
|
+
const stats = {
|
|
748
|
+
bucket: this.bucketName,
|
|
749
|
+
prefix: this.prefix,
|
|
750
|
+
localDirectory: this.localDirectory,
|
|
751
|
+
localScannedCount: localFiles.length,
|
|
752
|
+
localIncludedCount,
|
|
753
|
+
uploadedCount,
|
|
754
|
+
remoteListedCount: remoteFiles.length,
|
|
755
|
+
removedRemoteCount,
|
|
756
|
+
elapsedMs,
|
|
757
|
+
phases: {
|
|
758
|
+
mkdirMs,
|
|
759
|
+
createClientMs,
|
|
760
|
+
listLocalMs,
|
|
761
|
+
uploadMs,
|
|
762
|
+
listRemoteMs,
|
|
763
|
+
cleanupMs
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
this.onDebugEvent?.("sync_up.complete", stats);
|
|
767
|
+
return stats;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
function isTruthy(value) {
|
|
771
|
+
if (typeof value === "boolean") {
|
|
772
|
+
return value;
|
|
773
|
+
}
|
|
774
|
+
if (typeof value === "string") {
|
|
775
|
+
return /^(1|true|yes|on)$/i.test(value);
|
|
776
|
+
}
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
function firstNonEmpty(...values) {
|
|
780
|
+
for (const value of values) {
|
|
781
|
+
if (typeof value === "string" && value.trim()) {
|
|
782
|
+
return value.trim();
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return void 0;
|
|
786
|
+
}
|
|
787
|
+
const inFlightOperations = /* @__PURE__ */ new Map();
|
|
788
|
+
const debouncedOperations = /* @__PURE__ */ new Map();
|
|
789
|
+
const lastSuccessfulSyncAtByScope = /* @__PURE__ */ new Map();
|
|
790
|
+
function toInt(value) {
|
|
791
|
+
if (!value) return void 0;
|
|
792
|
+
const parsed = Number.parseInt(value, 10);
|
|
793
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
794
|
+
}
|
|
795
|
+
function resolveFreshnessTtlMs(config) {
|
|
796
|
+
const fromConfig = config?.cloud?.syncFreshnessTtlMs;
|
|
797
|
+
if (typeof fromConfig === "number" && Number.isFinite(fromConfig) && fromConfig >= 0) {
|
|
798
|
+
return fromConfig;
|
|
799
|
+
}
|
|
800
|
+
return toInt(process.env.RIOTPLAN_CLOUD_SYNC_FRESHNESS_TTL_MS) || 5e3;
|
|
801
|
+
}
|
|
802
|
+
function resolveSyncTimeoutMs(config) {
|
|
803
|
+
const fromConfig = config?.cloud?.syncTimeoutMs;
|
|
804
|
+
if (typeof fromConfig === "number" && Number.isFinite(fromConfig) && fromConfig > 0) {
|
|
805
|
+
return fromConfig;
|
|
806
|
+
}
|
|
807
|
+
return toInt(process.env.RIOTPLAN_CLOUD_SYNC_TIMEOUT_MS) || 12e4;
|
|
808
|
+
}
|
|
809
|
+
function resolveSyncUpDebounceMs(_config) {
|
|
810
|
+
const fromEnv = toInt(process.env.RIOTPLAN_CLOUD_SYNC_UP_DEBOUNCE_MS);
|
|
811
|
+
if (typeof fromEnv === "number") {
|
|
812
|
+
return fromEnv;
|
|
813
|
+
}
|
|
814
|
+
return 400;
|
|
815
|
+
}
|
|
816
|
+
async function runCoalescedOperation(key, operation, options) {
|
|
817
|
+
const existing = inFlightOperations.get(key);
|
|
818
|
+
if (existing) {
|
|
819
|
+
existing.waiterCount += 1;
|
|
820
|
+
const result2 = await existing.promise;
|
|
821
|
+
return {
|
|
822
|
+
result: result2,
|
|
823
|
+
coalesced: true,
|
|
824
|
+
waiterCount: existing.waiterCount
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
const timeoutMs = options?.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : 0;
|
|
828
|
+
const entry = {
|
|
829
|
+
waiterCount: 0,
|
|
830
|
+
promise: (async () => {
|
|
831
|
+
try {
|
|
832
|
+
if (!timeoutMs) {
|
|
833
|
+
return await operation();
|
|
834
|
+
}
|
|
835
|
+
return await Promise.race([
|
|
836
|
+
operation(),
|
|
837
|
+
new Promise((_, reject) => {
|
|
838
|
+
setTimeout(() => {
|
|
839
|
+
reject(new Error(`Coalesced operation timed out after ${timeoutMs}ms`));
|
|
840
|
+
}, timeoutMs);
|
|
841
|
+
})
|
|
842
|
+
]);
|
|
843
|
+
} finally {
|
|
844
|
+
inFlightOperations.delete(key);
|
|
845
|
+
}
|
|
846
|
+
})()
|
|
847
|
+
};
|
|
848
|
+
inFlightOperations.set(key, entry);
|
|
849
|
+
const result = await entry.promise;
|
|
850
|
+
return {
|
|
851
|
+
result,
|
|
852
|
+
coalesced: false,
|
|
853
|
+
waiterCount: entry.waiterCount
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
async function runDebouncedCoalescedOperation(key, operation, options) {
|
|
857
|
+
const existing = debouncedOperations.get(key);
|
|
858
|
+
if (existing) {
|
|
859
|
+
existing.waiterCount += 1;
|
|
860
|
+
const result = await existing.promise;
|
|
861
|
+
return {
|
|
862
|
+
result,
|
|
863
|
+
coalesced: true,
|
|
864
|
+
waiterCount: existing.waiterCount
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const debounceMs = options?.debounceMs && options.debounceMs > 0 ? options.debounceMs : 0;
|
|
868
|
+
const timeoutMs = options?.timeoutMs && options.timeoutMs > 0 ? options.timeoutMs : 0;
|
|
869
|
+
let timer;
|
|
870
|
+
const promise = new Promise((resolve2, reject) => {
|
|
871
|
+
const execute = async () => {
|
|
872
|
+
const run = async () => operation();
|
|
873
|
+
const task = timeoutMs ? Promise.race([
|
|
874
|
+
run(),
|
|
875
|
+
new Promise((_, timeoutReject) => {
|
|
876
|
+
setTimeout(() => {
|
|
877
|
+
timeoutReject(new Error(`Debounced operation timed out after ${timeoutMs}ms`));
|
|
878
|
+
}, timeoutMs);
|
|
879
|
+
})
|
|
880
|
+
]) : run();
|
|
881
|
+
try {
|
|
882
|
+
resolve2(await task);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
reject(error);
|
|
885
|
+
} finally {
|
|
886
|
+
debouncedOperations.delete(key);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
if (debounceMs > 0) {
|
|
890
|
+
timer = setTimeout(() => {
|
|
891
|
+
void execute();
|
|
892
|
+
}, debounceMs);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
void execute();
|
|
896
|
+
});
|
|
897
|
+
const state = {
|
|
898
|
+
promise,
|
|
899
|
+
waiterCount: 0
|
|
900
|
+
};
|
|
901
|
+
debouncedOperations.set(key, state);
|
|
902
|
+
try {
|
|
903
|
+
const result = await promise;
|
|
904
|
+
return {
|
|
905
|
+
result,
|
|
906
|
+
coalesced: false,
|
|
907
|
+
waiterCount: state.waiterCount
|
|
908
|
+
};
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (timer) {
|
|
911
|
+
clearTimeout(timer);
|
|
912
|
+
}
|
|
913
|
+
throw error;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
async function createCloudRuntime(config, localPlanDirectory, diagnostics) {
|
|
917
|
+
const cloudConfig = config?.cloud;
|
|
918
|
+
const incrementalSyncEnabled = cloudConfig?.incrementalSyncEnabled !== false;
|
|
919
|
+
const freshnessTtlMs = incrementalSyncEnabled ? resolveFreshnessTtlMs(config) : 0;
|
|
920
|
+
const syncTimeoutMs = resolveSyncTimeoutMs(config);
|
|
921
|
+
const syncUpDebounceMs = resolveSyncUpDebounceMs();
|
|
922
|
+
const enabled = isTruthy(cloudConfig?.enabled) || isTruthy(process.env.RIOTPLAN_CLOUD_ENABLED) || isTruthy(process.env.RIOTPLAN_GCS_ENABLED);
|
|
923
|
+
if (!enabled) {
|
|
924
|
+
return {
|
|
925
|
+
enabled: false,
|
|
926
|
+
workingDirectory: localPlanDirectory,
|
|
927
|
+
contextDirectory: localPlanDirectory,
|
|
928
|
+
syncDown: async () => ({
|
|
929
|
+
plan: null,
|
|
930
|
+
context: null,
|
|
931
|
+
syncFreshHit: false,
|
|
932
|
+
coalescedWaiterCount: 0
|
|
933
|
+
}),
|
|
934
|
+
syncUpPlans: async () => null,
|
|
935
|
+
syncUpContext: async () => null
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
const planBucket = firstNonEmpty(
|
|
939
|
+
cloudConfig?.planBucket,
|
|
940
|
+
process.env.RIOTPLAN_PLAN_BUCKET,
|
|
941
|
+
process.env.RIOTPLAN_GCS_PLAN_BUCKET
|
|
942
|
+
);
|
|
943
|
+
const contextBucket = firstNonEmpty(
|
|
944
|
+
cloudConfig?.contextBucket,
|
|
945
|
+
process.env.RIOTPLAN_CONTEXT_BUCKET,
|
|
946
|
+
process.env.RIOTPLAN_GCS_CONTEXT_BUCKET
|
|
947
|
+
);
|
|
948
|
+
if (!planBucket || !contextBucket) {
|
|
949
|
+
throw new Error(
|
|
950
|
+
"Cloud mode enabled but missing bucket config. Set cloud.planBucket + cloud.contextBucket or RIOTPLAN_PLAN_BUCKET + RIOTPLAN_CONTEXT_BUCKET."
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
const cacheRoot = resolve(
|
|
954
|
+
firstNonEmpty(cloudConfig?.cacheDirectory, process.env.RIOTPLAN_CLOUD_CACHE_DIR) || join(localPlanDirectory, ".cloud-cache")
|
|
955
|
+
);
|
|
956
|
+
const planMirrorDir = join(cacheRoot, "plans");
|
|
957
|
+
const contextMirrorDir = join(cacheRoot, "context");
|
|
958
|
+
await mkdir(planMirrorDir, { recursive: true });
|
|
959
|
+
await mkdir(contextMirrorDir, { recursive: true });
|
|
960
|
+
const auth = {
|
|
961
|
+
projectId: firstNonEmpty(cloudConfig?.projectId, process.env.GOOGLE_CLOUD_PROJECT),
|
|
962
|
+
keyFilename: firstNonEmpty(cloudConfig?.keyFilename, process.env.GOOGLE_APPLICATION_CREDENTIALS),
|
|
963
|
+
credentialsJson: firstNonEmpty(cloudConfig?.credentialsJson, process.env.GOOGLE_CREDENTIALS_JSON)
|
|
964
|
+
};
|
|
965
|
+
const planMirror = new GcsMirror({
|
|
966
|
+
auth,
|
|
967
|
+
location: {
|
|
968
|
+
bucket: planBucket,
|
|
969
|
+
prefix: cloudConfig?.planPrefix
|
|
970
|
+
},
|
|
971
|
+
localDirectory: planMirrorDir,
|
|
972
|
+
includeFile: (relativePath) => relativePath.endsWith(".plan"),
|
|
973
|
+
incrementalSyncEnabled,
|
|
974
|
+
onDebugEvent: (event, details) => {
|
|
975
|
+
diagnostics?.debug?.(`plan.${event}`, details);
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
const contextMirror = new GcsMirror({
|
|
979
|
+
auth,
|
|
980
|
+
location: {
|
|
981
|
+
bucket: contextBucket,
|
|
982
|
+
prefix: cloudConfig?.contextPrefix
|
|
983
|
+
},
|
|
984
|
+
localDirectory: contextMirrorDir,
|
|
985
|
+
incrementalSyncEnabled,
|
|
986
|
+
onDebugEvent: (event, details) => {
|
|
987
|
+
diagnostics?.debug?.(`context.${event}`, details);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
return {
|
|
991
|
+
enabled: true,
|
|
992
|
+
workingDirectory: planMirrorDir,
|
|
993
|
+
contextDirectory: contextMirrorDir,
|
|
994
|
+
syncDown: async (options) => {
|
|
995
|
+
const startedAt = Date.now();
|
|
996
|
+
const planScope = `sync_down:${planMirrorDir}:plan`;
|
|
997
|
+
const contextScope = `sync_down:${contextMirrorDir}:context`;
|
|
998
|
+
const now = Date.now();
|
|
999
|
+
const planFresh = freshnessTtlMs > 0 && now - (lastSuccessfulSyncAtByScope.get(planScope) || 0) <= freshnessTtlMs;
|
|
1000
|
+
const contextFresh = freshnessTtlMs > 0 && now - (lastSuccessfulSyncAtByScope.get(contextScope) || 0) <= freshnessTtlMs;
|
|
1001
|
+
if (!options?.forceRefresh && planFresh && contextFresh) {
|
|
1002
|
+
diagnostics?.debug?.("sync_down.fresh_hit", {
|
|
1003
|
+
freshnessTtlMs,
|
|
1004
|
+
planScope,
|
|
1005
|
+
contextScope
|
|
1006
|
+
});
|
|
1007
|
+
return {
|
|
1008
|
+
plan: null,
|
|
1009
|
+
context: null,
|
|
1010
|
+
syncFreshHit: true,
|
|
1011
|
+
coalescedWaiterCount: 0
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
diagnostics?.debug?.("sync_down.start", {
|
|
1015
|
+
planBucket,
|
|
1016
|
+
contextBucket,
|
|
1017
|
+
planMirrorDir,
|
|
1018
|
+
contextMirrorDir,
|
|
1019
|
+
incrementalSyncEnabled,
|
|
1020
|
+
freshnessTtlMs,
|
|
1021
|
+
syncTimeoutMs
|
|
1022
|
+
});
|
|
1023
|
+
const [planSync, contextSync] = await Promise.all([
|
|
1024
|
+
runCoalescedOperation(planScope, () => planMirror.syncDown(), { timeoutMs: syncTimeoutMs }),
|
|
1025
|
+
runCoalescedOperation(contextScope, () => contextMirror.syncDown(), { timeoutMs: syncTimeoutMs })
|
|
1026
|
+
]);
|
|
1027
|
+
const planStats = planSync.result;
|
|
1028
|
+
const contextStats = contextSync.result;
|
|
1029
|
+
lastSuccessfulSyncAtByScope.set(planScope, Date.now());
|
|
1030
|
+
lastSuccessfulSyncAtByScope.set(contextScope, Date.now());
|
|
1031
|
+
const coalescedWaiterCount = planSync.waiterCount + contextSync.waiterCount;
|
|
1032
|
+
const syncFreshHit = planStats.downloadedCount === 0 && contextStats.downloadedCount === 0;
|
|
1033
|
+
diagnostics?.debug?.("sync_down.complete", {
|
|
1034
|
+
elapsedMs: Date.now() - startedAt,
|
|
1035
|
+
syncFreshHit,
|
|
1036
|
+
coalescedWaiterCount,
|
|
1037
|
+
syncOutcome: syncFreshHit ? "fresh-hit" : "full-or-incremental",
|
|
1038
|
+
plan: planStats,
|
|
1039
|
+
context: contextStats
|
|
1040
|
+
});
|
|
1041
|
+
return {
|
|
1042
|
+
plan: planStats,
|
|
1043
|
+
context: contextStats,
|
|
1044
|
+
syncFreshHit,
|
|
1045
|
+
coalescedWaiterCount
|
|
1046
|
+
};
|
|
1047
|
+
},
|
|
1048
|
+
syncUpPlans: async () => {
|
|
1049
|
+
const startedAt = Date.now();
|
|
1050
|
+
diagnostics?.debug?.("sync_up_plans.start", { planBucket, planMirrorDir });
|
|
1051
|
+
const syncUpScope = `sync_up:${planMirrorDir}:plan`;
|
|
1052
|
+
const syncResult = await runDebouncedCoalescedOperation(
|
|
1053
|
+
syncUpScope,
|
|
1054
|
+
() => planMirror.syncUp(),
|
|
1055
|
+
{ debounceMs: syncUpDebounceMs, timeoutMs: syncTimeoutMs }
|
|
1056
|
+
);
|
|
1057
|
+
const stats = syncResult.result;
|
|
1058
|
+
if (syncResult.coalesced || syncResult.waiterCount > 0) {
|
|
1059
|
+
diagnostics?.debug?.("sync_up_plans.coalesced", {
|
|
1060
|
+
scope: syncUpScope,
|
|
1061
|
+
coalesced: syncResult.coalesced,
|
|
1062
|
+
coalescedWaiterCount: syncResult.waiterCount,
|
|
1063
|
+
debounceMs: syncUpDebounceMs
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
diagnostics?.debug?.("sync_up_plans.complete", {
|
|
1067
|
+
elapsedMs: Date.now() - startedAt,
|
|
1068
|
+
plan: stats,
|
|
1069
|
+
coalesced: syncResult.coalesced,
|
|
1070
|
+
coalescedWaiterCount: syncResult.waiterCount,
|
|
1071
|
+
debounceMs: syncUpDebounceMs
|
|
1072
|
+
});
|
|
1073
|
+
return stats;
|
|
1074
|
+
},
|
|
1075
|
+
syncUpContext: async () => {
|
|
1076
|
+
const startedAt = Date.now();
|
|
1077
|
+
diagnostics?.debug?.("sync_up_context.start", { contextBucket, contextMirrorDir });
|
|
1078
|
+
const syncUpScope = `sync_up:${contextMirrorDir}:context`;
|
|
1079
|
+
const syncResult = await runDebouncedCoalescedOperation(
|
|
1080
|
+
syncUpScope,
|
|
1081
|
+
() => contextMirror.syncUp(),
|
|
1082
|
+
{ debounceMs: syncUpDebounceMs, timeoutMs: syncTimeoutMs }
|
|
1083
|
+
);
|
|
1084
|
+
const stats = syncResult.result;
|
|
1085
|
+
if (syncResult.coalesced || syncResult.waiterCount > 0) {
|
|
1086
|
+
diagnostics?.debug?.("sync_up_context.coalesced", {
|
|
1087
|
+
scope: syncUpScope,
|
|
1088
|
+
coalesced: syncResult.coalesced,
|
|
1089
|
+
coalescedWaiterCount: syncResult.waiterCount,
|
|
1090
|
+
debounceMs: syncUpDebounceMs
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
diagnostics?.debug?.("sync_up_context.complete", {
|
|
1094
|
+
elapsedMs: Date.now() - startedAt,
|
|
1095
|
+
context: stats,
|
|
1096
|
+
coalesced: syncResult.coalesced,
|
|
1097
|
+
coalescedWaiterCount: syncResult.waiterCount,
|
|
1098
|
+
debounceMs: syncUpDebounceMs
|
|
1099
|
+
});
|
|
1100
|
+
return stats;
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
export {
|
|
1105
|
+
GcsMirror,
|
|
1106
|
+
buildSyncDiff,
|
|
1107
|
+
createCloudRuntime,
|
|
1108
|
+
loadSyncManifest,
|
|
1109
|
+
runCoalescedOperation,
|
|
1110
|
+
runDebouncedCoalescedOperation,
|
|
1111
|
+
writeSyncManifest
|
|
1112
|
+
};
|
|
1113
|
+
//# sourceMappingURL=index.js.map
|