@indigoai-us/hq-cloud 6.14.10 → 6.14.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/s3.ts CHANGED
@@ -253,6 +253,89 @@ function removeTempPath(tempPath: string): void {
253
253
  }
254
254
  }
255
255
 
256
+ export interface ReplaceStagedPathOps {
257
+ lstat(path: string): fs.Stats;
258
+ rename(from: string, to: string): void;
259
+ remove(path: string): void;
260
+ }
261
+
262
+ const DEFAULT_REPLACE_STAGED_PATH_OPS: ReplaceStagedPathOps = {
263
+ lstat: (p) => fs.lstatSync(p),
264
+ rename: (from, to) => fs.renameSync(from, to),
265
+ remove: (p) => fs.unlinkSync(p),
266
+ };
267
+
268
+ function replacementBackupPath(localPath: string): string {
269
+ const dir = path.dirname(localPath);
270
+ const random = crypto.randomBytes(10).toString("hex");
271
+ return path.join(dir, `.hq-backup-${random}`);
272
+ }
273
+
274
+ /**
275
+ * Install a fully-staged download over its destination.
276
+ *
277
+ * A direct rename is atomic and remains the fast path for absent destinations
278
+ * and ordinary files. Windows cannot rename over an existing directory link,
279
+ * though, because MoveFileEx treats that link as an existing directory. For a
280
+ * symlink/junction destination, move the old link to a sibling backup first,
281
+ * install the staged entry, then remove the backup. Any failure after the
282
+ * backup move restores the exact prior link before rethrowing.
283
+ *
284
+ * The tiny operations seam keeps the Windows failure/rollback contract
285
+ * deterministic in ESM tests without spying on non-configurable fs exports.
286
+ */
287
+ export function replaceStagedPath(
288
+ stagedPath: string,
289
+ localPath: string,
290
+ ops: ReplaceStagedPathOps = DEFAULT_REPLACE_STAGED_PATH_OPS,
291
+ ): void {
292
+ let existing: fs.Stats;
293
+ try {
294
+ existing = ops.lstat(localPath);
295
+ } catch (err) {
296
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
297
+ ops.rename(stagedPath, localPath);
298
+ return;
299
+ }
300
+ throw err;
301
+ }
302
+
303
+ if (!existing.isSymbolicLink()) {
304
+ ops.rename(stagedPath, localPath);
305
+ return;
306
+ }
307
+
308
+ const backupPath = replacementBackupPath(localPath);
309
+ ops.rename(localPath, backupPath);
310
+ let stagedInstalled = false;
311
+ try {
312
+ ops.rename(stagedPath, localPath);
313
+ stagedInstalled = true;
314
+ ops.remove(backupPath);
315
+ } catch (primaryError) {
316
+ const rollbackErrors: unknown[] = [];
317
+ if (stagedInstalled) {
318
+ try {
319
+ ops.rename(localPath, stagedPath);
320
+ } catch (rollbackError) {
321
+ rollbackErrors.push(rollbackError);
322
+ }
323
+ }
324
+ try {
325
+ ops.rename(backupPath, localPath);
326
+ } catch (rollbackError) {
327
+ rollbackErrors.push(rollbackError);
328
+ }
329
+ if (rollbackErrors.length > 0) {
330
+ throw new AggregateError(
331
+ [primaryError, ...rollbackErrors],
332
+ "Staged path replacement failed and the previous directory link could not be fully restored",
333
+ );
334
+ }
335
+ throw primaryError;
336
+ }
337
+ }
338
+
256
339
  async function getObjectStream(
257
340
  io: ObjectIO,
258
341
  key: string,
@@ -904,7 +987,7 @@ export async function downloadFile(
904
987
  try {
905
988
  fs.symlinkSync(symlinkTarget, tempPath);
906
989
  options.beforeReplace?.();
907
- fs.renameSync(tempPath, localPath);
990
+ replaceStagedPath(tempPath, localPath);
908
991
  } catch (err) {
909
992
  removeTempPath(tempPath);
910
993
  throw err;
@@ -1014,7 +1097,7 @@ export async function downloadFile(
1014
1097
  }
1015
1098
 
1016
1099
  options.beforeReplace?.();
1017
- fs.renameSync(tempPath, localPath);
1100
+ replaceStagedPath(tempPath, localPath);
1018
1101
  tempReady = false;
1019
1102
  } finally {
1020
1103
  if (tempReady) removeTempPath(tempPath);