sliftutils 1.7.139 → 1.7.141

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.
@@ -3,7 +3,7 @@ import { delay } from "socket-function/src/batching";
3
3
  import {
4
4
  IArchives, RemoteConfig, RemoteConfigBase, SourceConfig,
5
5
  ArchiveFileInfo, ArchivesConfig, ArchivesSyncStatus, ChangesAfterConfig, DelConfig, FindConfig, GetConfig, GetInfoConfig, MoveFileConfig, SetConfig, SetLargeFileConfig, STORAGE_WRONG_VALID_WINDOW,
6
- STORAGE_WRONG_ROUTE, STORAGE_NOT_CONFIGURED, FULL_ROUTE, VARIABLE_SHARD, LARGE_SET_THRESHOLD, bufferChunkStream,
6
+ STORAGE_WRONG_ROUTE, STORAGE_NOT_CONFIGURED, FULL_ROUTE, VARIABLE_SHARD, LARGE_SET_THRESHOLD, bufferChunkStream, validateFileName,
7
7
  } from "../IArchives";
8
8
  import { copyArchiveFile } from "../archiveHelpers";
9
9
  import {
@@ -83,7 +83,7 @@ function coverRoutes(candidates: SourceWrapper[]): SourceWrapper[] | undefined {
83
83
  return chosen;
84
84
  }
85
85
 
86
- /** READS ONLY. Drops sources that recently failed while disconnected - unless that would leave nothing, in which case a down source is still better than no source, and we retry it immediately. Never applies to writes (or noFallbacks reads of the write target): the write node is strictly the FIRST source matching the route and valid window, regardless of connectivity - a client's flaky view of the network must never scatter writes across the chain (spec: client writes are consistent, client reads are redundant). */
86
+ /** READS ONLY. Drops sources that recently failed while disconnected - unless that would leave nothing, in which case a down source is still better than no source, and we retry it immediately. Never applies to writes (or fallbacks:false reads of the write target): the write node is strictly the FIRST source matching the route and valid window, regardless of connectivity - a client's flaky view of the network must never scatter writes across the chain (spec: client writes are consistent, client reads are redundant). */
87
87
  function preferUsable(sources: SourceWrapper[]): SourceWrapper[] {
88
88
  let usable = sources.filter(x => !x.isOnCooldown());
89
89
  return usable.length && usable || sources;
@@ -106,12 +106,12 @@ export class ArchivesChain implements IArchives {
106
106
  return `chain ${urls.join(", ")}`;
107
107
  }
108
108
 
109
- // The ONE dispatch for every operation: no fallbacks (all writes by default, and noFallbacks reads) -> the primary node only, via runPrimary; everything else -> the shared fallback loop, trying sources in config order (or latency order for fast reads) and only moving on when one fails. Writes in the loop differ from reads only in calling source.write.
110
- private async run<T>(state: ChainState, config: { apiOnly?: boolean; write?: boolean; route?: number; noFallbacks?: boolean; fallbacks?: boolean; retries?: number; fast?: boolean; timeout?: SmartTimeout }, run: (archives: IArchives, sourceUrl: string) => Promise<T>): Promise<T> {
111
- if (config.fast && config.noFallbacks) {
112
- throw new Error(`fast and noFallbacks are mutually exclusive for ${this.getDebugName()}: noFallbacks only considers one source (the write node), so there is no order to speed up`);
109
+ // The ONE dispatch for every operation, on ONE flag: fallbacks false -> the primary node only, via runPrimary; fallbacks true -> the shared fallback loop, trying sources in config order (or latency order for fast reads) and moving on whenever one fails. Every caller sets fallbacks unconditionally - reads turn it on unless the caller said noFallbacks, writes turn it off unless the caller said fallbacks. Writes in the loop differ from reads only in calling source.write.
110
+ private async run<T>(state: ChainState, config: { fallbacks: boolean; apiOnly?: boolean; write?: boolean; route?: number; retries?: number; fast?: boolean; timeout?: SmartTimeout }, run: (archives: IArchives, sourceUrl: string) => Promise<T>): Promise<T> {
111
+ if (config.fast && !config.fallbacks) {
112
+ throw new Error(`fast requires fallbacks for ${this.getDebugName()}: without fallbacks only one source (the write node) is considered, so there is no order to speed up`);
113
113
  }
114
- if (config.noFallbacks || config.write && !config.fallbacks) {
114
+ if (!config.fallbacks) {
115
115
  return await this.runPrimary(config, run);
116
116
  }
117
117
  let retries = config.retries;
@@ -163,16 +163,11 @@ export class ArchivesChain implements IArchives {
163
163
  await this.prepareWrongTargetRetry(state, message.includes(STORAGE_WRONG_VALID_WINDOW) && "window" || message.includes(STORAGE_WRONG_ROUTE) && "route" || "unconfigured");
164
164
  break;
165
165
  }
166
- // fallbacks means availability above everything: ANY failing source - down, misconfigured, rejecting, mid-switchover - is skipped and the next covering source takes the call. Only every source failing throws (below). Without fallbacks, a CONNECTED source's error is a real answer and throws.
167
- if (config.fallbacks) {
168
- console.error(`Source failed for ${this.getDebugName()}, falling back to the next source: ${message}`);
169
- if (!source.isConnected()) source.noteFailure();
170
- errors.push(message);
171
- continue;
172
- }
173
- if (source.isConnected()) throw e;
174
- source.noteFailure();
166
+ // fallbacks means availability above everything: ANY failing source - down, misconfigured, rejecting, mid-switchover - is skipped and the next covering source takes the call. Only every source failing throws (below).
167
+ console.error(`Source failed for ${this.getDebugName()}, falling back to the next source: ${message}`);
168
+ if (!source.isConnected()) source.noteFailure();
175
169
  errors.push(message);
170
+ continue;
176
171
  }
177
172
  }
178
173
  if (wrongTarget) {
@@ -198,7 +193,7 @@ export class ArchivesChain implements IArchives {
198
193
  }
199
194
  }
200
195
 
201
- // Writes and noFallbacks reads are the same case: take the authoritative node - strictly the first source matching the route and valid window, whether it is up or down - and use it, never falling back to another node. It's important that writing always accesses the same node everywhere, even if that node is down - otherwise we're just writing into the void, and who knows if the writes will even be accepted, or clobbered, or what; and noFallbacks reads want the same node precisely because it is the one writes target. A slow call is almost always better than throwing, so a failing primary is retried (the SAME node, re-resolved each attempt since a config refresh can change which source is primary) until the deadline, then throws.
196
+ // Writes and fallbacks:false reads are the same case: take the authoritative node - strictly the first source matching the route and valid window, whether it is up or down - and use it, never falling back to another node. It's important that writing always accesses the same node everywhere, even if that node is down - otherwise we're just writing into the void, and who knows if the writes will even be accepted, or clobbered, or what; and fallbacks:false reads want the same node precisely because it is the one writes target. A slow call is almost always better than throwing, so a failing primary is retried (the SAME node, re-resolved each attempt since a config refresh can change which source is primary) until the deadline, then throws.
202
197
  private async runPrimary<T>(config: { write?: boolean; route?: number; timeout?: SmartTimeout }, run: (archives: IArchives, sourceUrl: string) => Promise<T>): Promise<T> {
203
198
  let retriedWrongWindow = false;
204
199
  let retriedWrongRoute = false;
@@ -314,7 +309,7 @@ export class ArchivesChain implements IArchives {
314
309
  await this.state.refreshActiveConfig();
315
310
  }
316
311
 
317
- private async request<T>(config: { apiOnly?: boolean; write?: boolean; route?: number; noFallbacks?: boolean; fallbacks?: boolean; retries?: number; fast?: boolean; timeout?: SmartTimeout }, run: (archives: IArchives, sourceUrl: string) => Promise<T>): Promise<T> {
312
+ private async request<T>(config: { fallbacks: boolean; apiOnly?: boolean; write?: boolean; route?: number; retries?: number; fast?: boolean; timeout?: SmartTimeout }, run: (archives: IArchives, sourceUrl: string) => Promise<T>): Promise<T> {
318
313
  let state = await this.state.getState();
319
314
  return await this.run(state, config, run);
320
315
  }
@@ -355,11 +350,12 @@ export class ArchivesChain implements IArchives {
355
350
  }
356
351
  /** get2, but trying sources in latency order (fastest first) instead of config order. While this is much faster, it might miss immediate writes: the write node is no longer tried first, so a lagging replica may answer with a slightly older value. Exclusive with noFallbacks (which only considers one source - the write node - so there is no order to speed up); passing both throws. */
357
352
  public async getFast(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
353
+ validateFileName(fileName, "getFast");
358
354
  if (config?.sourceUrl) {
359
355
  // A specific source leaves nothing for the latency ordering to decide
360
356
  return await this.get2(fileName, config);
361
357
  }
362
- return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, retries: config?.retries, fast: true, timeout: { path: fileName } }, async (archives, url) => {
358
+ return await this.request({ fallbacks: !config?.noFallbacks, route: getRoute(fileName), retries: config?.retries, fast: true, timeout: { path: fileName } }, async (archives, url) => {
363
359
  let result = await archives.get2(fileName, config);
364
360
  // Empty data is a tombstone, not content - see get2
365
361
  if (!result || !result.data || !result.data.length && !config?.includeTombstones && !(config?.range && result.size)) return { url };
@@ -368,6 +364,7 @@ export class ArchivesChain implements IArchives {
368
364
  }
369
365
  /** Always resolves with a url - the authority that answered. A value that doesn't exist is still an answer FROM a server, so it comes back as { url } with no data (never plain undefined); errors from every source throw instead. */
370
366
  public async get2(fileName: string, config?: GetConfig): Promise<{ data: Buffer; writeTime: number; size: number; url: string } | { data?: undefined; writeTime?: undefined; size?: undefined; url: string }> {
367
+ validateFileName(fileName, "get2");
371
368
  const sourceUrl = config?.sourceUrl;
372
369
  if (sourceUrl) {
373
370
  return await this.runOnSource(sourceUrl, async archives => {
@@ -376,7 +373,7 @@ export class ArchivesChain implements IArchives {
376
373
  return { data: result.data, writeTime: result.writeTime, size: result.size, url: sourceUrl };
377
374
  });
378
375
  }
379
- return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, retries: config?.retries, timeout: { path: fileName } }, async (archives, url) => {
376
+ return await this.request({ fallbacks: !config?.noFallbacks, route: getRoute(fileName), retries: config?.retries, timeout: { path: fileName } }, async (archives, url) => {
380
377
  let result = await archives.get2(fileName, config);
381
378
  // Empty data is a tombstone, not content (unless the caller asked for tombstones) - a ranged read of a REAL file can legitimately be empty though (range past EOF), which the total size distinguishes
382
379
  if (!result || !result.data || !result.data.length && !config?.includeTombstones && !(config?.range && result.size)) return { url };
@@ -384,6 +381,7 @@ export class ArchivesChain implements IArchives {
384
381
  });
385
382
  }
386
383
  public async getInfo(fileName: string, config?: GetInfoConfig): Promise<{ writeTime: number; size: number; url: string } | undefined> {
384
+ validateFileName(fileName, "getInfo");
387
385
  const sourceUrl = config?.sourceUrl;
388
386
  if (sourceUrl) {
389
387
  return await this.runOnSource(sourceUrl, async archives => {
@@ -391,15 +389,15 @@ export class ArchivesChain implements IArchives {
391
389
  return result && { ...result, url: sourceUrl } || undefined;
392
390
  });
393
391
  }
394
- return await this.request({ route: getRoute(fileName), noFallbacks: config?.noFallbacks, retries: config?.retries }, async (archives, url) => {
392
+ return await this.request({ fallbacks: !config?.noFallbacks, route: getRoute(fileName), retries: config?.retries }, async (archives, url) => {
395
393
  let result = await archives.getInfo(fileName, config);
396
394
  return result && { ...result, url } || undefined;
397
395
  });
398
396
  }
399
397
 
400
398
  // Without fallbacks: the AUTHORITATIVE covering ONLY - the first source per route in config order (the same node every write and read targets), down or not. It is NEVER excluded and NEVER substituted, so a listing that can't reach its write nodes retries those same nodes until the deadline and then fails, rather than quietly reading second-hand data off a replica or backblaze. exclude/cooldown/substitution apply ONLY when the caller opted into fallbacks.
401
- private selectCoveringSources(state: ChainState, config?: { fallbacks?: boolean; exclude?: Set<SourceWrapper> }): SourceWrapper[] {
402
- if (config?.fallbacks) {
399
+ private selectCoveringSources(state: ChainState, config: { fallbacks: boolean; exclude?: Set<SourceWrapper> }): SourceWrapper[] {
400
+ if (config.fallbacks) {
403
401
  let candidates = state.sources.filter(x => configWindowCurrent(x.config) && x.api && !config.exclude?.has(x));
404
402
  let usable = candidates.filter(x => !x.isOnCooldown());
405
403
  let chosen = coverRoutes(usable) || coverRoutes(candidates);
@@ -414,7 +412,7 @@ export class ArchivesChain implements IArchives {
414
412
  return chosen;
415
413
  }
416
414
 
417
- private async runOnCovering<T>(operation: string, run: (archives: IArchives) => Promise<T>, config?: { fallbacks?: boolean }): Promise<T[]> {
415
+ private async runOnCovering<T>(operation: string, run: (archives: IArchives) => Promise<T>, config: { fallbacks: boolean }): Promise<T[]> {
418
416
  let startTime = Date.now();
419
417
  let deadline = Date.now() + COVERING_RETRY_TIMEOUT;
420
418
  // Sources that failed during this call - with fallbacks, the next attempt covers their routes with the next source holding them instead
@@ -428,7 +426,7 @@ export class ArchivesChain implements IArchives {
428
426
  let selected: SourceWrapper[] | undefined;
429
427
  let coverGap: Error | undefined;
430
428
  try {
431
- selected = this.selectCoveringSources(state, { fallbacks: config?.fallbacks, exclude: failed });
429
+ selected = this.selectCoveringSources(state, { fallbacks: config.fallbacks, exclude: failed });
432
430
  } catch (e) {
433
431
  coverGap = e as Error;
434
432
  }
@@ -437,10 +435,10 @@ export class ArchivesChain implements IArchives {
437
435
  let error = new Error(
438
436
  `${operation} cannot run: ${coverGap!.message}`
439
437
  + (described ? ` ${allFailures.size} source(s) failed first: ${described}.` : "")
440
- + ` Fallbacks = ${!!config?.fallbacks}. Tries = ${tries}, Took ${formatTime(Date.now() - startTime)}`
438
+ + ` Fallbacks = ${config.fallbacks}. Tries = ${tries}, Took ${formatTime(Date.now() - startTime)}`
441
439
  );
442
440
  // With fallbacks the loop only lands here after every substitute was tried, so waiting will not produce a new source - the caller gets the whole cascade now
443
- if (config?.fallbacks && allFailures.size) {
441
+ if (config.fallbacks && allFailures.size) {
444
442
  throw error;
445
443
  }
446
444
  if (Date.now() >= deadline) {
@@ -476,10 +474,10 @@ export class ArchivesChain implements IArchives {
476
474
  allFailures.set(failure.source, failure.error);
477
475
  }
478
476
  let error = failures.length === 1
479
- ? new Error(`${operation} failed because source ${failures[0].source.getDebugName()} is unavailable: ${failures[0].error.message ?? failures[0].error}. Fallbacks = ${!!config?.fallbacks}. Tries = ${tries}, Took ${formatTime(Date.now() - startTime)}`)
480
- : new Error(`${operation} failed because ${failures.length} of the ${covering.length} sources covering this attempt are unavailable: ${failures.map(x => `${x.source.getDebugName()}: ${x.error.message ?? x.error}`).join(" | ")}. Fallbacks = ${!!config?.fallbacks}. Tries = ${tries}, Took ${formatTime(Date.now() - startTime)}`);
477
+ ? new Error(`${operation} failed because source ${failures[0].source.getDebugName()} is unavailable: ${failures[0].error.message ?? failures[0].error}. Fallbacks = ${config.fallbacks}. Tries = ${tries}, Took ${formatTime(Date.now() - startTime)}`)
478
+ : new Error(`${operation} failed because ${failures.length} of the ${covering.length} sources covering this attempt are unavailable: ${failures.map(x => `${x.source.getDebugName()}: ${x.error.message ?? x.error}`).join(" | ")}. Fallbacks = ${config.fallbacks}. Tries = ${tries}, Took ${formatTime(Date.now() - startTime)}`);
481
479
  // Substitution comes BEFORE the deadline check: a single connect timeout can eat the entire deadline, and giving up then - without ever trying the substitute that fallbacks exist for - throws exactly when falling back matters most. The loop keeps substituting for as long as a covering set exists; every pass adds its failures to `failed`, so it always terminates at the no-covering branch above, which throws everything collected here.
482
- if (config?.fallbacks) {
480
+ if (config.fallbacks) {
483
481
  console.warn(`(retrying with fallbacks) ${error.message}`);
484
482
  continue;
485
483
  }
@@ -496,7 +494,7 @@ export class ArchivesChain implements IArchives {
496
494
  return (await this.findInfo(prefix, config)).map(x => x.path);
497
495
  }
498
496
  public async findInfo(prefix: string, config?: FindConfig): Promise<ArchiveFileInfo[]> {
499
- let results = await this.runOnCovering(`The find of ${JSON.stringify(prefix)}`, archives => archives.findInfo(prefix, config), { fallbacks: config?.fallbacks });
497
+ let results = await this.runOnCovering(`The find of ${JSON.stringify(prefix)}`, archives => archives.findInfo(prefix, config), { fallbacks: !!config?.fallbacks });
500
498
  let byPath = new Map<string, ArchiveFileInfo>();
501
499
  for (let list of results) {
502
500
  for (let file of list) {
@@ -511,7 +509,7 @@ export class ArchivesChain implements IArchives {
511
509
  return merged;
512
510
  }
513
511
  public async getChangesAfter2(config: ChangesAfterConfig): Promise<ArchiveFileInfo[]> {
514
- let results = await this.runOnCovering(`The changes listing since ${formatDateTimeDetailed(config.time)}`, archives => archives.getChangesAfter2(config));
512
+ let results = await this.runOnCovering(`The changes listing since ${formatDateTimeDetailed(config.time)}`, archives => archives.getChangesAfter2(config), { fallbacks: false });
515
513
  let byPath = new Map<string, ArchiveFileInfo>();
516
514
  for (let list of results) {
517
515
  for (let file of list) {
@@ -531,7 +529,7 @@ export class ArchivesChain implements IArchives {
531
529
  throw new Error(`getSyncStatus is not supported: ${archives.getDebugName()} does not implement it`);
532
530
  }
533
531
  return await archives.getSyncStatus();
534
- });
532
+ }, { fallbacks: false });
535
533
  return {
536
534
  allScansComplete: statuses.every(x => x.allScansComplete),
537
535
  indexSize: statuses.reduce((sum, x) => sum + x.indexSize, 0),
@@ -541,7 +539,7 @@ export class ArchivesChain implements IArchives {
541
539
  public async getConfig(): Promise<ArchivesConfig> {
542
540
  let state = await this.state.getState();
543
541
  if (!state.sources.some(x => x.api)) return { remoteConfig: state.config };
544
- let config = await this.run(state, { apiOnly: true }, archives => archives.getConfig());
542
+ let config = await this.run(state, { fallbacks: true, apiOnly: true }, archives => archives.getConfig());
545
543
  return { ...config, remoteConfig: state.config };
546
544
  }
547
545
  public async hasWriteAccess(): Promise<boolean> {
@@ -554,6 +552,7 @@ export class ArchivesChain implements IArchives {
554
552
  }
555
553
 
556
554
  public async set(fileName: string, data: Buffer, config?: SetConfig): Promise<string> {
555
+ validateFileName(fileName, "set");
557
556
  if (!data.length) {
558
557
  throw new Error(`Empty write refused: set was called with an empty buffer for ${JSON.stringify(fileName)}: an empty file IS a deletion in this system and would read back as missing - call del instead`);
559
558
  }
@@ -568,7 +567,7 @@ export class ArchivesChain implements IArchives {
568
567
  await this.setLargeFile({ path: fileName, ...config, ...bufferChunkStream(data) });
569
568
  return fileName;
570
569
  }
571
- await this.request({ write: true, fallbacks: config?.fallbacks, retries: config?.retries, route: getRoute(fileName), timeout: { uploadBytes: data.length, label: `Upload of ${JSON.stringify(fileName)} (${data.length} bytes)` } }, archives => archives.set(fileName, data, config));
570
+ await this.request({ fallbacks: !!config?.fallbacks, write: true, retries: config?.retries, route: getRoute(fileName), timeout: { uploadBytes: data.length, label: `Upload of ${JSON.stringify(fileName)} (${data.length} bytes)` } }, archives => archives.set(fileName, data, config));
572
571
  return fileName;
573
572
  }
574
573
 
@@ -610,14 +609,16 @@ export class ArchivesChain implements IArchives {
610
609
  return ROUTING_FILE;
611
610
  }
612
611
  public async del(fileName: string, config?: DelConfig): Promise<void> {
613
- await this.request({ write: true, fallbacks: config?.fallbacks, retries: config?.retries, route: getRoute(fileName), timeout: { uploadBytes: 0, label: `Deletion of ${JSON.stringify(fileName)}` } }, archives => archives.del(fileName, config));
612
+ validateFileName(fileName, "del");
613
+ await this.request({ fallbacks: !!config?.fallbacks, write: true, retries: config?.retries, route: getRoute(fileName), timeout: { uploadBytes: 0, label: `Deletion of ${JSON.stringify(fileName)}` } }, archives => archives.del(fileName, config));
614
614
  }
615
615
 
616
616
  /** See IArchives.undelete: restores a file marked for deletion, dispatched to the write node as SetConfig.undelete (the write node propagates the restore to its peers itself). */
617
617
  public async undelete(fileName: string): Promise<void> {
618
+ validateFileName(fileName, "undelete");
618
619
  // set refuses empty buffers, and an undelete carries no data - the byte is ignored
619
620
  let placeholder = Buffer.from([1]);
620
- await this.request({ write: true, route: getRoute(fileName), timeout: { uploadBytes: placeholder.length, label: `Undelete of ${JSON.stringify(fileName)}` } }, archives => archives.set(fileName, placeholder, { undelete: true }));
621
+ await this.request({ fallbacks: false, write: true, route: getRoute(fileName), timeout: { uploadBytes: placeholder.length, label: `Undelete of ${JSON.stringify(fileName)}` } }, archives => archives.set(fileName, placeholder, { undelete: true }));
621
622
  }
622
623
 
623
624
  /** See IArchives.move. When one node is the write target for BOTH paths, that node moves the file itself - the bytes never come through us - with the same wrong-window/route re-resolution as any write. When the paths route to different shards no single node holds both, so the move degrades to a copy through us plus a delete, CONFIRMED at the destination before the source is touched. No smart timeout on the node-side move: it can be a big file's worth of node-side work, which the upload-sized deadlines would misjudge. */
@@ -631,7 +632,7 @@ export class ArchivesChain implements IArchives {
631
632
  let state = await this.state.getState();
632
633
  let target = state.sources.find(x => configWindowCurrent(x.config) && routeContains(x.config.route, fromRoute));
633
634
  if (target && routeContains(target.config.route, toRoute)) {
634
- await this.request({ write: true, route: fromRoute }, async archives => {
635
+ await this.request({ fallbacks: false, write: true, route: fromRoute }, async archives => {
635
636
  if (!archives.move) {
636
637
  throw new Error(`Move is not supported by this source: ${archives.getDebugName()} (moving ${JSON.stringify(config.fromPath)} to ${JSON.stringify(config.toPath)})`);
637
638
  }
@@ -732,6 +733,7 @@ export class ArchivesChain implements IArchives {
732
733
 
733
734
  /** A large file is written exactly like a small one - same write node, same wrong-window/route re-resolution, same fallbacks - so a value's SIZE never decides its write semantics (set streams through here past LARGE_SET_THRESHOLD, and a file that grew past it must not suddenly lose the availability its caller asked for). The one difference: every attempt after the first has to rewind the stream, so a config without restartStream gets a single attempt. */
734
735
  public async setLargeFile(config: SetLargeFileConfig): Promise<void> {
736
+ validateFileName(config.path, "setLargeFile");
735
737
  if (config.path.includes(VARIABLE_SHARD) && parseVariableRoute(config.path) === undefined) {
736
738
  throw new Error(`setLargeFile does not support VARIABLE_SHARD keys (there is no way to return the materialized key); write the file with set, or materialize the key yourself. Key: ${JSON.stringify(config.path)}`);
737
739
  }
@@ -742,7 +744,7 @@ export class ArchivesChain implements IArchives {
742
744
  return;
743
745
  }
744
746
  let attempt = 0;
745
- await this.request({ write: true, fallbacks: config.fallbacks, retries: config.retries, route }, async archives => {
747
+ await this.request({ fallbacks: !!config.fallbacks, write: true, retries: config.retries, route }, async archives => {
746
748
  attempt++;
747
749
  // The previous attempt consumed some (or all) of the stream, and this source needs the file from its first byte
748
750
  if (attempt > 1) await restartStream();