sliftutils 1.7.115 → 1.7.116

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/index.d.ts CHANGED
@@ -2213,7 +2213,7 @@ declare module "sliftutils/storage/IArchives" {
2213
2213
  routes?: [number, number][];
2214
2214
  };
2215
2215
  export type SetConfig = {
2216
- /** The write time to stamp (see IArchives.set). FLOORED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. */
2216
+ /** The write time to stamp (see IArchives.set). ROUNDED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. Rounded rather than floored because utimes goes through a seconds double and can read back a hair below the stamped millisecond (see ArchivesDisk.get2). */
2217
2217
  lastModified?: number;
2218
2218
  /** Makes the write acceptable on immutable targets: an existing path is simply kept (immutability wins - nothing is overwritten) instead of the write throwing. Requires lastModified. Synchronization MUST pass this on every push - a plain set throws on immutable targets, which would abort reconciliation whenever one source in a chain is immutable. */
2219
2219
  forceSetImmutable?: boolean;
@@ -2773,7 +2773,7 @@ declare module "sliftutils/storage/TransactionFile" {
2773
2773
  entries(): IterableIterator<[string, LogEntry<T>]>;
2774
2774
  /** The tombstones, which is a much smaller walk than the values - so expiring them, or listing what was deleted since some time, costs what it should. */
2775
2775
  deletedEntries(): IterableIterator<[string, LogTombstone<T>]>;
2776
- /** Stores a value as of `time` (floored to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
2776
+ /** Stores a value as of `time` (rounded to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
2777
2777
  set(key: string, value: T, time: number): boolean;
2778
2778
  /** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
2779
2779
  delete(key: string, time: number): boolean;
@@ -2856,13 +2856,15 @@ declare module "sliftutils/storage/TransactionStorage" {
2856
2856
 
2857
2857
  declare module "sliftutils/storage/archiveHelpers" {
2858
2858
  import type { IArchives } from "./IArchives";
2859
- /** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, the destination already had a NEWER file (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
2859
+ /** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, and with preserveWriteTime the two guarded cases - the destination already held something NEWER (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
2860
2860
  export declare function copyArchiveFile(config: {
2861
2861
  from: IArchives;
2862
2862
  to: IArchives;
2863
2863
  path: string;
2864
2864
  /** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
2865
2865
  toPath?: string;
2866
+ /** Stamps the destination with the SOURCE's write time instead of now, and turns on the ordering guards around it (the newer-destination refusal up front, and the getInfo confirm after). ONLY for synchronization between replicas of the same key, where the higher write time must win and ordering must survive propagation - never for a user-triggered copy: a plain copy is a NEW write, and the source's old stamp would make it LOSE to any newer write or tombstone at the destination, silently (move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely). */
2867
+ preserveWriteTime?: boolean;
2866
2868
  forceSetImmutable?: boolean;
2867
2869
  noChecks?: boolean;
2868
2870
  internal?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.7.115",
3
+ "version": "1.7.116",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -164,8 +164,8 @@ export class ArchivesDisk implements IArchives {
164
164
  await this.handles.run(filePath, async () => {
165
165
  if (lastModified) {
166
166
  let existing = await statOrUndefined(filePath);
167
- // An older write never overwrites a newer one (see IArchives.set). Both sides floored: sub-millisecond mtime digits are storage artifacts, not ordering (see get2)
168
- if (existing && Math.floor(lastModified) < Math.floor(existing.mtimeMs)) return;
167
+ // An older write never overwrites a newer one (see IArchives.set). Both sides rounded: sub-millisecond mtime digits are storage artifacts, not ordering (see get2)
168
+ if (existing && Math.round(lastModified) < Math.round(existing.mtimeMs)) return;
169
169
  }
170
170
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
171
171
  let handle = await this.handles.getHandle(filePath, fs.constants.O_RDWR | fs.constants.O_CREAT);
@@ -238,14 +238,14 @@ export class ArchivesDisk implements IArchives {
238
238
  if (!size && !config?.includeTombstones) return undefined;
239
239
  let start = range && Math.min(range.start, size) || 0;
240
240
  let end = range && Math.min(range.end, size) || size;
241
- // mtimeMs is floored everywhere this disk reports a time: it carries fractional milliseconds (kernel-stamped files have nanosecond mtimes), but utimes round-trips only whole ones - so a fractional time handed to the rest of the system could never be reproduced by propagation, and every copy of the file would compare "older" than the original forever
242
- if (end <= start) return { data: Buffer.alloc(0), writeTime: Math.floor(stats.mtimeMs), size };
241
+ // mtimeMs is ROUNDED everywhere this disk reports a time: it carries fractional milliseconds (kernel-stamped files have nanosecond mtimes), but utimes round-trips only whole ones - so a fractional time handed to the rest of the system could never be reproduced by propagation, and every copy of the file would compare "older" than the original forever. Round rather than floor, because utimes goes through a seconds DOUBLE and can land a hair BELOW the stamped millisecond - flooring turned that representation error into a full lost millisecond, un-reproducing our own stamps.
242
+ if (end <= start) return { data: Buffer.alloc(0), writeTime: Math.round(stats.mtimeMs), size };
243
243
  let buffer = Buffer.alloc(end - start);
244
244
  let { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
245
245
  if (bytesRead !== buffer.length) {
246
246
  throw new Error(`Expected ${buffer.length} bytes at ${filePath}:${start}, read ${bytesRead}`);
247
247
  }
248
- return { data: buffer, writeTime: Math.floor(stats.mtimeMs), size };
248
+ return { data: buffer, writeTime: Math.round(stats.mtimeMs), size };
249
249
  });
250
250
  }
251
251
 
@@ -256,7 +256,7 @@ export class ArchivesDisk implements IArchives {
256
256
  let stats = await statOrUndefined(filePath);
257
257
  if (!stats || !stats.isFile()) return undefined;
258
258
  if (!stats.size && !config?.includeTombstones) return undefined;
259
- return { writeTime: Math.floor(stats.mtimeMs), size: stats.size };
259
+ return { writeTime: Math.round(stats.mtimeMs), size: stats.size };
260
260
  });
261
261
  }
262
262
 
@@ -297,7 +297,7 @@ export class ArchivesDisk implements IArchives {
297
297
  let stats = await statOrUndefined(path.join(this.filesDir, relPath));
298
298
  // Deleted while we were walking
299
299
  if (!stats) continue;
300
- infos.set(relPath, { path: relPath, createTime: Math.floor(stats.mtimeMs), size: stats.size });
300
+ infos.set(relPath, { path: relPath, createTime: Math.round(stats.mtimeMs), size: stats.size });
301
301
  }
302
302
  }
303
303
 
@@ -355,7 +355,7 @@ export class ArchivesDisk implements IArchives {
355
355
  if (lastModified) {
356
356
  // An older write never overwrites a newer one (see IArchives.set) - re-checked HERE, not just when the upload started, because a large upload streams for minutes and that window is exactly when a newer write lands
357
357
  let existing = await statOrUndefined(filePath);
358
- if (existing && Math.floor(lastModified) < Math.floor(existing.mtimeMs)) {
358
+ if (existing && Math.round(lastModified) < Math.round(existing.mtimeMs)) {
359
359
  await fs.promises.rm(tmpPath, { force: true });
360
360
  return;
361
361
  }
@@ -118,7 +118,7 @@ export type ChangesAfterConfig = {
118
118
  routes?: [number, number][];
119
119
  };
120
120
  export type SetConfig = {
121
- /** The write time to stamp (see IArchives.set). FLOORED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. */
121
+ /** The write time to stamp (see IArchives.set). ROUNDED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. Rounded rather than floored because utimes goes through a seconds double and can read back a hair below the stamped millisecond (see ArchivesDisk.get2). */
122
122
  lastModified?: number;
123
123
  /** Makes the write acceptable on immutable targets: an existing path is simply kept (immutability wins - nothing is overwritten) instead of the write throwing. Requires lastModified. Synchronization MUST pass this on every push - a plain set throws on immutable targets, which would abort reconciliation whenever one source in a chain is immutable. */
124
124
  forceSetImmutable?: boolean;
@@ -158,7 +158,7 @@ export type ChangesAfterConfig = {
158
158
  };
159
159
 
160
160
  export type SetConfig = {
161
- /** The write time to stamp (see IArchives.set). FLOORED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. */
161
+ /** The write time to stamp (see IArchives.set). ROUNDED to whole milliseconds by every implementation - the disk can't store fractional milliseconds anyway (utimes round-trips whole ms), so a fractional stamp could never be reproduced by propagation and would compare "newer" than its own copies forever. Rounded rather than floored because utimes goes through a seconds double and can read back a hair below the stamped millisecond (see ArchivesDisk.get2). */
162
162
  lastModified?: number;
163
163
  /** Makes the write acceptable on immutable targets: an existing path is simply kept (immutability wins - nothing is overwritten) instead of the write throwing. Requires lastModified. Synchronization MUST pass this on every push - a plain set throws on immutable targets, which would abort reconciliation whenever one source in a chain is immutable. */
164
164
  forceSetImmutable?: boolean;
@@ -182,7 +182,7 @@ export type SetLargeFileConfig = SetConfig & {
182
182
  restartStream?(): Promise<void> | void;
183
183
  };
184
184
 
185
- // createTime is a misnomer kept for compatibility — it is really the LAST-WRITE time, same as getInfo's writeTime. Neither Backblaze nor our remote storage tracks a distinct creation date: each write stamps a fresh timestamp on the current version, so both fields are just "when the bytes served by get() were most recently written". Always WHOLE milliseconds: write times are floored at every producer (see SetConfig.lastModified).
185
+ // createTime is a misnomer kept for compatibility — it is really the LAST-WRITE time, same as getInfo's writeTime. Neither Backblaze nor our remote storage tracks a distinct creation date: each write stamps a fresh timestamp on the current version, so both fields are just "when the bytes served by get() were most recently written". Always WHOLE milliseconds: write times are rounded at every producer (see SetConfig.lastModified).
186
186
  export type ArchiveFileInfo = { path: string; createTime: number; size: number };
187
187
 
188
188
  // An in-progress background synchronization task (see ArchivesConfig.syncing)
@@ -39,7 +39,7 @@ export declare class TransactionFile<T> {
39
39
  entries(): IterableIterator<[string, LogEntry<T>]>;
40
40
  /** The tombstones, which is a much smaller walk than the values - so expiring them, or listing what was deleted since some time, costs what it should. */
41
41
  deletedEntries(): IterableIterator<[string, LogTombstone<T>]>;
42
- /** Stores a value as of `time` (floored to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
42
+ /** Stores a value as of `time` (rounded to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
43
43
  set(key: string, value: T, time: number): boolean;
44
44
  /** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
45
45
  delete(key: string, time: number): boolean;
@@ -119,9 +119,9 @@ export class TransactionFile<T> {
119
119
  return this.deleted.entries();
120
120
  }
121
121
 
122
- /** Stores a value as of `time` (floored to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
122
+ /** Stores a value as of `time` (rounded to whole milliseconds - see applySet). Returns false when something at least as new is already here, in which case nothing changed - an out-of-order write is not an error, it is just late. */
123
123
  public set(key: string, value: T, time: number): boolean {
124
- time = Math.floor(time);
124
+ time = Math.round(time);
125
125
  if (!this.applySet(key, value, time, Date.now())) return false;
126
126
  this.append({ k: key, t: time, v: value });
127
127
  return true;
@@ -129,7 +129,7 @@ export class TransactionFile<T> {
129
129
 
130
130
  /** Deletes as of `time`, keeping the tombstone. A key that had a live value keeps it in the tombstone as MARKED for deletion (readable and restorable until dropValue). Returns false when something at least as new is already here. */
131
131
  public delete(key: string, time: number): boolean {
132
- time = Math.floor(time);
132
+ time = Math.round(time);
133
133
  let live = this.values.get(key);
134
134
  if (!this.applyDelete(key, time, Date.now(), live?.value, live?.time)) return false;
135
135
  if (live) {
@@ -142,7 +142,7 @@ export class TransactionFile<T> {
142
142
 
143
143
  /** Undoes a marked deletion: the kept value becomes live again, as of `time` (a fresh time, so the restore outranks the deletion everywhere it propagated). Returns false when there is no marked value to restore, or something at least as new is already here. */
144
144
  public unmark(key: string, time: number): boolean {
145
- time = Math.floor(time);
145
+ time = Math.round(time);
146
146
  let tombstone = this.deleted.get(key);
147
147
  if (!tombstone || tombstone.value === undefined) return false;
148
148
  if (!this.applySet(key, tombstone.value, time, Date.now())) return false;
@@ -163,13 +163,13 @@ export class TransactionFile<T> {
163
163
  let had = this.values.delete(key);
164
164
  had = this.deleted.delete(key) || had;
165
165
  if (!had) return;
166
- this.append({ k: key, t: Math.floor(Date.now()), p: 1 });
166
+ this.append({ k: key, t: Math.round(Date.now()), p: 1 });
167
167
  }
168
168
 
169
- // Times are floored to whole milliseconds on every path into the maps (load replays through here too, so fractional times persisted by older code heal on startup). Disk mtimes carry fractional milliseconds but utimes round-trips only whole ones, so a fractional time can never be reproduced by propagation - every copy of the value would compare "older" than the original forever, and the same write would be re-pushed and re-copied every round.
169
+ // Times are ROUNDED to whole milliseconds on every path into the maps (load replays through here too, so fractional times persisted by older code heal on startup). Disk mtimes carry fractional milliseconds but utimes round-trips only whole ones, so a fractional time can never be reproduced by propagation - every copy of the value would compare "older" than the original forever, and the same write would be re-pushed and re-copied every round. Round rather than floor, to match ArchivesDisk (see its get2): utimes goes through a seconds double and can land a hair below the stamped millisecond.
170
170
  private applySet(key: string, value: T, time: number, changedAt: number): boolean {
171
- time = Math.floor(time);
172
- changedAt = Math.floor(changedAt);
171
+ time = Math.round(time);
172
+ changedAt = Math.round(changedAt);
173
173
  if (time < this.timeOf(key)) return false;
174
174
  this.deleted.delete(key);
175
175
  this.values.set(key, { value, time, changedAt });
@@ -177,10 +177,10 @@ export class TransactionFile<T> {
177
177
  }
178
178
 
179
179
  private applyDelete(key: string, time: number, changedAt: number, value?: T, valueTime?: number): boolean {
180
- time = Math.floor(time);
181
- changedAt = Math.floor(changedAt);
180
+ time = Math.round(time);
181
+ changedAt = Math.round(changedAt);
182
182
  if (valueTime !== undefined) {
183
- valueTime = Math.floor(valueTime);
183
+ valueTime = Math.round(valueTime);
184
184
  }
185
185
  if (time < this.timeOf(key)) return false;
186
186
  this.values.delete(key);
@@ -1,11 +1,13 @@
1
1
  import type { IArchives } from "./IArchives";
2
- /** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, the destination already had a NEWER file (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
2
+ /** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, and with preserveWriteTime the two guarded cases - the destination already held something NEWER (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
3
3
  export declare function copyArchiveFile(config: {
4
4
  from: IArchives;
5
5
  to: IArchives;
6
6
  path: string;
7
7
  /** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
8
8
  toPath?: string;
9
+ /** Stamps the destination with the SOURCE's write time instead of now, and turns on the ordering guards around it (the newer-destination refusal up front, and the getInfo confirm after). ONLY for synchronization between replicas of the same key, where the higher write time must win and ordering must survive propagation - never for a user-triggered copy: a plain copy is a NEW write, and the source's old stamp would make it LOSE to any newer write or tombstone at the destination, silently (move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely). */
10
+ preserveWriteTime?: boolean;
9
11
  forceSetImmutable?: boolean;
10
12
  noChecks?: boolean;
11
13
  internal?: boolean;
@@ -8,13 +8,15 @@ import { logStorageError } from "./remoteStorage/storageLogs";
8
8
  const LARGE_COPY_THRESHOLD = 64 * 1024 * 1024;
9
9
  const LARGE_COPY_CHUNK = 32 * 1024 * 1024;
10
10
 
11
- /** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, the destination already had a NEWER file (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
11
+ /** Copies one file between two archives. The source's CURRENT size and write time always come from getInfo right here - callers never supply them, because a stale size turns into ranged reads of a file that has changed (failing forever), and a stale write time re-orders history. Small files go as a single get2+set; past LARGE_COPY_THRESHOLD the copy streams through setLargeFile in LARGE_COPY_CHUNK ranged reads, so the whole file is never in memory. Returns the copied file's info, and undefined for every way the copy did NOT land: the source doesn't have the file, and with preserveWriteTime the two guarded cases - the destination already held something NEWER (refused up front rather than roll it back), or the destination silently dropped the write (its own only-take-latest won a race we lost - caught by confirming with getInfo afterward). The refused/dropped cases are logged as errors; a caller that treats undefined as "missing at the source" must getInfo the destination to learn the actual latest value. */
12
12
  export async function copyArchiveFile(config: {
13
13
  from: IArchives;
14
14
  to: IArchives;
15
15
  path: string;
16
16
  /** The path at the destination - defaults to path (the common case: the same key moving between two archives). */
17
17
  toPath?: string;
18
+ /** Stamps the destination with the SOURCE's write time instead of now, and turns on the ordering guards around it (the newer-destination refusal up front, and the getInfo confirm after). ONLY for synchronization between replicas of the same key, where the higher write time must win and ordering must survive propagation - never for a user-triggered copy: a plain copy is a NEW write, and the source's old stamp would make it LOSE to any newer write or tombstone at the destination, silently (move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely). */
19
+ preserveWriteTime?: boolean;
18
20
  forceSetImmutable?: boolean;
19
21
  noChecks?: boolean;
20
22
  internal?: boolean;
@@ -25,14 +27,15 @@ export async function copyArchiveFile(config: {
25
27
  let info = await from.getInfo(path, { noFallbacks: config.noFallbacks });
26
28
  if (!info) return undefined;
27
29
  let size = info.size;
28
- // internal (synchronization between replicas of the same key) preserves the source's write time, so ordering survives propagation. A plain copy is a NEW write and is stamped now: the source's old stamp would make it LOSE to any newer write or tombstone at the destination, silently - move a file back to a folder it was deleted from and the copy is dropped, then the caller deletes the source, and the file is gone entirely.
29
- let writeTime = Math.floor(config.internal && info.writeTime || Date.now());
30
- // A destination that already holds a NEWER file must not be overwritten with our older one - and the destination's own only-take-latest would drop the write SILENTLY, leaving the caller believing the copy happened. Refusing here, loudly, is what turns "the remote has something we missed" from a masked bug into a log line the caller can act on.
31
- let destInfo = await to.getInfo(toPath, { noFallbacks: config.noFallbacks });
32
- // Compared at whole-millisecond precision, here and at the confirm below: disk mtimes carry fractional milliseconds, but utimes round-trips only whole ones, so sub-millisecond differences are storage artifacts of the SAME time, not ordering
33
- if (destInfo && Math.floor(destInfo.writeTime) > Math.floor(writeTime)) {
34
- logStorageError(`Copy refused - a newer file exists at the destination. Refusing to copy ${JSON.stringify(path)} from ${from.getDebugName()} to ${to.getDebugName()}${toPath !== path && ` (as ${JSON.stringify(toPath)})` || ""}: ours ${size} bytes at ${formatDateTimeDetailed(writeTime)}, theirs ${destInfo.size} bytes at ${formatDateTimeDetailed(destInfo.writeTime)} - copying would roll it back`);
35
- return undefined;
30
+ let writeTime = Math.round(config.preserveWriteTime && info.writeTime || Date.now());
31
+ if (config.preserveWriteTime) {
32
+ // A destination that already holds a NEWER file must not be overwritten with our older one - and the destination's own only-take-latest would drop the write SILENTLY, leaving the caller believing the copy happened. Refusing here, loudly, is what turns "the remote has something we missed" from a masked bug into a log line the caller can act on. (A fresh-stamped copy needs no such guard - nothing at the destination can be newer than now - so plain copies skip the round trip.)
33
+ let destInfo = await to.getInfo(toPath, { noFallbacks: config.noFallbacks });
34
+ // Compared at whole-millisecond precision (ROUNDED, matching ArchivesDisk - see its get2), here and at the confirm below: disk mtimes carry fractional milliseconds, but utimes round-trips only whole ones, so sub-millisecond differences are storage artifacts of the SAME time, not ordering
35
+ if (destInfo && Math.round(destInfo.writeTime) > Math.round(writeTime)) {
36
+ logStorageError(`Copy refused - a newer file exists at the destination. Refusing to copy ${JSON.stringify(path)} from ${from.getDebugName()} to ${to.getDebugName()}${toPath !== path && ` (as ${JSON.stringify(toPath)})` || ""}: ours ${size} bytes at ${formatDateTimeDetailed(writeTime)}, theirs ${destInfo.size} bytes at ${formatDateTimeDetailed(destInfo.writeTime)} - copying would roll it back`);
37
+ return undefined;
38
+ }
36
39
  }
37
40
  let copiedSize: number;
38
41
  if (size <= LARGE_COPY_THRESHOLD) {
@@ -67,11 +70,13 @@ export async function copyArchiveFile(config: {
67
70
  });
68
71
  copiedSize = totalSize;
69
72
  }
70
- // Every backend drops a superseded write SILENTLY (its only-take-latest is the last line of defense against races the up-front check can't see), so a returned set is not proof the copy landed - only the destination reporting the file at OUR time or newer is. Newer also counts as landed: the destination is at least as new as what we pushed (and b2 always stamps its own, later, upload time).
71
- let confirmed = await to.getInfo(toPath, { noFallbacks: config.noFallbacks });
72
- if (!confirmed || Math.floor(confirmed.writeTime) < Math.floor(writeTime)) {
73
- logStorageError(`Copy was silently dropped by the destination. Copy of ${JSON.stringify(path)} from ${from.getDebugName()} to ${to.getDebugName()}${toPath !== path && ` (as ${JSON.stringify(toPath)})` || ""}: our copy was ${copiedSize} bytes at ${formatDateTimeDetailed(writeTime)}, but the destination reports ${confirmed && `${confirmed.size} bytes at ${formatDateTimeDetailed(confirmed.writeTime)}` || "nothing"} (a newer write or deletion won the race)`);
74
- return undefined;
73
+ if (config.preserveWriteTime) {
74
+ // Every backend drops a superseded write SILENTLY (its only-take-latest is the last line of defense against races the up-front check can't see), so a returned set is not proof the copy landed - only the destination reporting the file at OUR time or newer is. Newer also counts as landed: the destination is at least as new as what we pushed (and b2 always stamps its own, later, upload time). Only for preserved stamps: a fresh stamp cannot lose the race, and moveArchiveFile does its own confirm before deleting anything.
75
+ let confirmed = await to.getInfo(toPath, { noFallbacks: config.noFallbacks });
76
+ if (!confirmed || Math.round(confirmed.writeTime) < Math.round(writeTime)) {
77
+ logStorageError(`Copy was silently dropped by the destination. Copy of ${JSON.stringify(path)} from ${from.getDebugName()} to ${to.getDebugName()}${toPath !== path && ` (as ${JSON.stringify(toPath)})` || ""}: our copy was ${copiedSize} bytes at ${formatDateTimeDetailed(writeTime)}, but the destination reports ${confirmed && `${confirmed.size} bytes at ${formatDateTimeDetailed(confirmed.writeTime)}` || "nothing"} (a newer write or deletion won the race)`);
78
+ return undefined;
79
+ }
75
80
  }
76
81
  return { writeTime, size: copiedSize };
77
82
  }