shopify-cli 2.18.1 → 2.20.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yaml +2 -1
  3. data/.github/ISSUE_TEMPLATE/config.yml +9 -0
  4. data/.github/workflows/cla.yml +22 -0
  5. data/CHANGELOG.md +23 -2
  6. data/Gemfile.lock +4 -4
  7. data/README.md +7 -6
  8. data/dev.yml +0 -1
  9. data/docs/users/installation.md +1 -1
  10. data/lib/project_types/extension/messages/messages.rb +1 -1
  11. data/lib/project_types/extension/models/development_server_requirements.rb +1 -6
  12. data/lib/project_types/extension/tasks/fetch_specifications.rb +4 -1
  13. data/lib/project_types/script/commands/create.rb +1 -1
  14. data/lib/project_types/script/config/extension_points.yml +15 -15
  15. data/lib/project_types/script/forms/ask_app.rb +0 -5
  16. data/lib/project_types/script/layers/domain/metadata.rb +3 -5
  17. data/lib/project_types/script/layers/infrastructure/script_service.rb +1 -1
  18. data/lib/project_types/theme/commands/push.rb +3 -1
  19. data/lib/project_types/theme/commands/serve.rb +1 -0
  20. data/lib/project_types/theme/messages/messages.rb +39 -2
  21. data/lib/shopify_cli/assets/post_auth_page/index.html.erb +34 -0
  22. data/lib/shopify_cli/assets/post_auth_page/style.css +58 -0
  23. data/lib/shopify_cli/identity_auth/servlet.rb +4 -20
  24. data/lib/shopify_cli/messages/messages.rb +6 -8
  25. data/lib/shopify_cli/theme/dev_server/hot-reload-no-script.html +27 -0
  26. data/lib/shopify_cli/theme/dev_server/hot-reload.js +16 -4
  27. data/lib/shopify_cli/theme/dev_server/hot_reload.rb +2 -0
  28. data/lib/shopify_cli/theme/dev_server.rb +3 -2
  29. data/lib/shopify_cli/theme/file.rb +5 -0
  30. data/lib/shopify_cli/theme/syncer/json_update_handler.rb +21 -7
  31. data/lib/shopify_cli/theme/syncer/operation.rb +7 -6
  32. data/lib/shopify_cli/theme/syncer/unsupported_script_warning.rb +90 -0
  33. data/lib/shopify_cli/theme/syncer.rb +81 -31
  34. data/lib/shopify_cli/theme/theme_admin_api.rb +16 -11
  35. data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk.rb +102 -0
  36. data/lib/shopify_cli/theme/theme_admin_api_throttler/bulk_job.rb +75 -0
  37. data/lib/shopify_cli/theme/theme_admin_api_throttler/errors.rb +7 -0
  38. data/lib/shopify_cli/theme/theme_admin_api_throttler/put_request.rb +52 -0
  39. data/lib/shopify_cli/theme/theme_admin_api_throttler/request_parser.rb +39 -0
  40. data/lib/shopify_cli/theme/theme_admin_api_throttler/response_parser.rb +21 -0
  41. data/lib/shopify_cli/theme/theme_admin_api_throttler.rb +62 -0
  42. data/lib/shopify_cli/version.rb +1 -1
  43. data/shopify-cli.gemspec +1 -1
  44. metadata +18 -6
  45. data/.github/probots.yml +0 -3
@@ -0,0 +1,27 @@
1
+ <noscript>
2
+ <style type="text/css">
3
+ .shopify-cli-no-script-message {
4
+ font-family: -apple-system, BlinkMacSystemFont, San Francisco, Segoe UI, Roboto, Helvetica Neue, sans-serif;
5
+ text-size-adjust: 100%;
6
+ text-rendering: optimizeLegibility;
7
+ -webkit-font-smoothing: antialiased;
8
+ -moz-osx-font-smoothing: grayscale;
9
+ position: fixed;
10
+ z-index: 999;
11
+ font-weight: 500;
12
+ left: 0;
13
+ top: 0;
14
+ width: 100vw;
15
+ height: 100vh;
16
+ padding: 10rem 0;
17
+ color: #202223;
18
+ text-align: center;
19
+ background-color: #F6F6F7;
20
+ }
21
+ </style>
22
+ <div class="shopify-cli-no-script-message">
23
+ Shopify CLI requires JavaScript to work.
24
+ <br />
25
+ Activate JavaScript support or try a different browser.
26
+ </div>
27
+ </noscript>
@@ -1,3 +1,15 @@
1
+ (() => {
2
+ function verifySSE() {
3
+ if (typeof (EventSource) === "undefined") {
4
+ console.error("[HotReload] Error: SSE features are not supported. Try a different browser.");
5
+ }
6
+ }
7
+
8
+ console.log("[HotReload] Initializing...");
9
+
10
+ verifySSE();
11
+ })();
12
+
1
13
  (() => {
2
14
  function connect() {
3
15
  const eventSource = new EventSource('/hot-reload');
@@ -32,7 +44,7 @@
32
44
 
33
45
  function fetchDOMSections(name) {
34
46
  const domSections = sectionNamesByType(name).flatMap((n) => querySelectDOMSections(n));
35
-
47
+
36
48
  if (domSections.length > 0) {
37
49
  return domSections;
38
50
  }
@@ -40,11 +52,11 @@
40
52
  return querySelectDOMSections(name);
41
53
  }
42
54
 
43
- function isFullPageReloadMode(){
55
+ function isFullPageReloadMode() {
44
56
  return reloadMode() === 'full-page';
45
57
  }
46
58
 
47
- function isReloadModeActive(){
59
+ function isReloadModeActive() {
48
60
  return reloadMode() !== 'off';
49
61
  }
50
62
 
@@ -72,7 +84,7 @@
72
84
 
73
85
  // Hot reload cookie expires in 3 seconds
74
86
  date.setSeconds(date.getSeconds() + 3);
75
-
87
+
76
88
  var sections = files.join(',');
77
89
  var expires = date.toUTCString();
78
90
 
@@ -67,7 +67,9 @@ module ShopifyCLI
67
67
 
68
68
  def inject_hot_reload_javascript(body)
69
69
  hot_reload_js = ::File.read("#{__dir__}/hot-reload.js")
70
+ hot_reload_no_script = ::File.read("#{__dir__}/hot-reload-no-script.html")
70
71
  hot_reload_script = [
72
+ hot_reload_no_script,
71
73
  "<script>",
72
74
  params_js,
73
75
  hot_reload_js,
@@ -28,11 +28,12 @@ module ShopifyCLI
28
28
  attr_accessor :ctx
29
29
 
30
30
  def start(ctx, root, host: "127.0.0.1", theme: nil, port: 9292, poll: false, editor_sync: false,
31
- mode: ReloadMode.default)
31
+ mode: ReloadMode.default, stable: false)
32
32
  @ctx = ctx
33
33
  theme = find_theme(root, theme)
34
34
  ignore_filter = IgnoreFilter.from_path(root)
35
- @syncer = Syncer.new(ctx, theme: theme, ignore_filter: ignore_filter, overwrite_json: !editor_sync)
35
+ @syncer = Syncer.new(ctx, theme: theme, ignore_filter: ignore_filter, overwrite_json: !editor_sync,
36
+ stable: stable)
36
37
  watcher = Watcher.new(ctx, theme: theme, ignore_filter: ignore_filter, syncer: @syncer, poll: poll)
37
38
  remote_watcher = RemoteWatcher.to(theme: theme, syncer: @syncer)
38
39
 
@@ -5,6 +5,7 @@ module ShopifyCLI
5
5
  module Theme
6
6
  class File < Struct.new(:path)
7
7
  attr_accessor :remote_checksum
8
+ attr_writer :warnings
8
9
 
9
10
  def initialize(path, root)
10
11
  super(Pathname.new(path))
@@ -104,6 +105,10 @@ module ShopifyCLI
104
105
  @relative_path.to_s
105
106
  end
106
107
 
108
+ def warnings
109
+ @warnings || []
110
+ end
111
+
107
112
  private
108
113
 
109
114
  def normalize_json(content)
@@ -8,16 +8,11 @@ module ShopifyCLI
8
8
  class Syncer
9
9
  module JsonUpdateHandler
10
10
  def enqueue_json_updates(files)
11
- # Some files must be uploaded after the other ones
12
- delayed_files = [
13
- theme["config/settings_schema.json"],
14
- theme["config/settings_data.json"],
15
- ]
16
-
17
11
  # Update remote JSON files and delays `delayed_files` update
18
12
  files = files
19
- .select { |file| !ignore_file?(file) && file.exist? && checksums.file_has_changed?(file) }
13
+ .select { |file| ready_to_update?(file) }
20
14
  .sort_by { |file| delayed_files.include?(file) ? 1 : 0 }
15
+ .reject { |file| overwrite_json? && delayed_files.include?(file) }
21
16
 
22
17
  if overwrite_json?
23
18
  enqueue_updates(files)
@@ -27,6 +22,21 @@ module ShopifyCLI
27
22
  end
28
23
  end
29
24
 
25
+ def enqueue_delayed_files_updates
26
+ return unless overwrite_json?
27
+ # Update delayed files synchronously
28
+ delayed_files.each do |file|
29
+ update(file) if ready_to_update?(file)
30
+ end
31
+ end
32
+
33
+ def delayed_files
34
+ [
35
+ theme["config/settings_schema.json"],
36
+ theme["config/settings_data.json"],
37
+ ]
38
+ end
39
+
30
40
  private
31
41
 
32
42
  def handle_update_conflicts(files)
@@ -76,6 +86,10 @@ module ShopifyCLI
76
86
  def ask_update_strategy(file)
77
87
  Forms::SelectUpdateStrategy.ask(@ctx, [], file: file, exists_remotely: file_exist_remotely?(file)).strategy
78
88
  end
89
+
90
+ def ready_to_update?(file)
91
+ !ignore_file?(file) && file.exist? && checksums.file_has_changed?(file)
92
+ end
79
93
  end
80
94
  end
81
95
  end
@@ -9,6 +9,7 @@ module ShopifyCLI
9
9
  COLOR_BY_STATUS = {
10
10
  error: :red,
11
11
  synced: :green,
12
+ warning: :yellow,
12
13
  fixed: :cyan,
13
14
  }
14
15
 
@@ -26,8 +27,8 @@ module ShopifyCLI
26
27
  as_message_with(status: :error)
27
28
  end
28
29
 
29
- def as_synced_message
30
- as_message_with(status: :synced)
30
+ def as_synced_message(color: :green)
31
+ as_message_with(status: :synced, color: color)
31
32
  end
32
33
 
33
34
  def as_fix_message
@@ -40,11 +41,11 @@ module ShopifyCLI
40
41
 
41
42
  private
42
43
 
43
- def as_message_with(status:)
44
- status_color = COLOR_BY_STATUS[status]
45
- status_text = @ctx.message("theme.serve.operation.status.#{status}").ljust(6)
44
+ def as_message_with(status:, color: nil)
45
+ color ||= COLOR_BY_STATUS[status]
46
+ text = @ctx.message("theme.serve.operation.status.#{status}").ljust(6)
46
47
 
47
- "#{timestamp} {{#{status_color}:#{status_text}}} {{>}} {{blue:#{self}}}"
48
+ "#{timestamp} {{#{color}:#{text}}} {{>}} {{blue:#{self}}}"
48
49
  end
49
50
 
50
51
  def timestamp
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyCLI
4
+ module Theme
5
+ class Syncer
6
+ class UnsupportedScriptWarning
7
+ attr_reader :ctx
8
+
9
+ def initialize(ctx, file)
10
+ @ctx = ctx
11
+ @file = file
12
+ end
13
+
14
+ def to_s
15
+ "\n\n#{occurrences} #{long_text}"
16
+ end
17
+
18
+ private
19
+
20
+ def occurrences
21
+ warnings.map { |w| occurrence(w) }.join("\n")
22
+ end
23
+
24
+ def occurrence(warning)
25
+ line_number = "{{blue: #{warning.line} |}}"
26
+ pointer = pointer_message(warning)
27
+
28
+ <<~OCCURRENCE
29
+ #{line_number} #{warning.line_content}
30
+ #{pointer}
31
+ OCCURRENCE
32
+ end
33
+
34
+ def long_text
35
+ lines_and_columns = warnings.map do |warning|
36
+ message("line_and_column", warning.line, warning.column)
37
+ end
38
+
39
+ message("unsupported_script_text", lines_and_columns.join)
40
+ .split("\n")
41
+ .reduce("") do |text, line|
42
+ # Add indentation in the long text to improve readability
43
+ line = " #{line}"
44
+
45
+ # Inline yellow (otherwise `CLI::UI::Frame` breaks multiline formatting)
46
+ line = "{{yellow:#{line}}}"
47
+
48
+ "#{text}#{line}\n"
49
+ end
50
+ end
51
+
52
+ def pointer_message(warning)
53
+ padding = warning.column + warning.line.to_s.size + 2
54
+ text = message("unsupported_script")
55
+
56
+ "{{yellow:#{" " * padding} ^ {{bold:#{text}}}}}"
57
+ end
58
+
59
+ def message(*args)
60
+ key = args.shift
61
+ @ctx.message("theme.serve.syncer.warnings.#{key}", *args)
62
+ end
63
+
64
+ def warnings
65
+ @warnings ||= @file.warnings.map { |w| Warning.new(@file, w) }
66
+ end
67
+
68
+ class Warning
69
+ attr_reader :line, :column
70
+
71
+ def initialize(file, warning_hash)
72
+ @file = file
73
+ @line = warning_hash["line"].to_i
74
+ @column = warning_hash["column"].to_i
75
+ end
76
+
77
+ def line_content
78
+ file_lines[line - 1]
79
+ end
80
+
81
+ private
82
+
83
+ def file_lines
84
+ @file_lines ||= @file.read.split("\n")
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -13,7 +13,9 @@ require_relative "syncer/json_update_handler"
13
13
  require_relative "syncer/merger"
14
14
  require_relative "syncer/operation"
15
15
  require_relative "syncer/standard_reporter"
16
+ require_relative "syncer/unsupported_script_warning"
16
17
  require_relative "theme_admin_api"
18
+ require_relative "theme_admin_api_throttler"
17
19
 
18
20
  module ShopifyCLI
19
21
  module Theme
@@ -31,12 +33,12 @@ module ShopifyCLI
31
33
  :union_merge, # - Union merges the local file content with the remote file content
32
34
  ]
33
35
 
34
- attr_reader :theme, :checksums, :error_checksums
36
+ attr_reader :theme, :checksums, :error_checksums, :api_client
35
37
  attr_accessor :include_filter, :ignore_filter
36
38
 
37
39
  def_delegators :@error_reporter, :has_any_error?
38
40
 
39
- def initialize(ctx, theme:, include_filter: nil, ignore_filter: nil, overwrite_json: true)
41
+ def initialize(ctx, theme:, include_filter: nil, ignore_filter: nil, overwrite_json: true, stable: false)
40
42
  @ctx = ctx
41
43
  @theme = theme
42
44
  @include_filter = include_filter
@@ -58,15 +60,21 @@ module ShopifyCLI
58
60
  # Mutex used to pause all threads when backing-off when hitting API rate limits
59
61
  @backoff_mutex = Mutex.new
60
62
 
63
+ # Mutex used to coordinate changes in the `pending` list
64
+ @pending_mutex = Mutex.new
65
+
61
66
  # Latest theme assets checksums. Updated on each upload.
62
67
  @checksums = Checksums.new(theme)
63
68
 
64
69
  # Checksums of assets with errors.
65
70
  @error_checksums = []
66
- end
67
71
 
68
- def api_client
69
- @api_client ||= ThemeAdminAPI.new(@ctx, @theme.shop)
72
+ # Initialize `api_client` on main thread
73
+ @api_client = ThemeAdminAPIThrottler.new(
74
+ @ctx,
75
+ ThemeAdminAPI.new(@ctx, @theme.shop),
76
+ !stable
77
+ )
70
78
  end
71
79
 
72
80
  def lock_io!
@@ -134,6 +142,7 @@ module ShopifyCLI
134
142
  end
135
143
 
136
144
  def shutdown
145
+ api_client.shutdown
137
146
  @queue.close unless @queue.closed?
138
147
  ensure
139
148
  @threads.each { |thread| thread.join if thread.alive? }
@@ -185,6 +194,9 @@ module ShopifyCLI
185
194
  unless delay_low_priority_files
186
195
  wait!(&block)
187
196
  end
197
+
198
+ api_client.deactivate_throttler!
199
+ enqueue_delayed_files_updates
188
200
  end
189
201
 
190
202
  def download_theme!(delete: true, &block)
@@ -242,38 +254,57 @@ module ShopifyCLI
242
254
  wait_for_backoff!
243
255
  @ctx.debug(operation.to_s)
244
256
 
245
- response = send(operation.method, operation.file)
257
+ send(operation.method, operation.file) do |response|
258
+ raise response if response.is_a?(StandardError)
246
259
 
247
- @standard_reporter.report(operation.as_synced_message)
260
+ file = operation.file
248
261
 
249
- # Check if the API told us we're near the rate limit
250
- if !backingoff? && (limit = response["x-shopify-shop-api-call-limit"])
251
- used, total = limit.split("/").map(&:to_i)
252
- backoff_if_near_limit!(used, total)
262
+ if file.warnings.any?
263
+ warning_message =
264
+ operation.as_synced_message(color: :yellow) +
265
+ UnsupportedScriptWarning.new(@ctx, file).to_s
266
+ @error_reporter.report(warning_message)
267
+ else
268
+ @standard_reporter.report(operation.as_synced_message)
269
+ end
270
+
271
+ # Check if the API told us we're near the rate limit
272
+ if !backingoff? && (limit = response["x-shopify-shop-api-call-limit"])
273
+ used, total = limit.split("/").map(&:to_i)
274
+ backoff_if_near_limit!(used, total)
275
+ end
276
+ rescue StandardError => error
277
+ handle_operation_error(operation, error)
278
+ ensure
279
+ @pending_mutex.synchronize do
280
+ # Avoid abrupt jumps in the progress bar
281
+ wait(0.05)
282
+ @pending.delete(operation)
283
+ end
253
284
  end
254
- rescue ShopifyCLI::API::APIRequestError => e
255
- error_suffix = ":\n " + parse_api_errors(e).join("\n ")
256
- report_error(operation, error_suffix)
257
- ensure
258
- @pending.delete(operation)
285
+ rescue StandardError => error
286
+ handle_operation_error(operation, error)
259
287
  end
260
288
 
261
289
  def update(file)
262
290
  asset = { key: file.relative_path }
291
+
263
292
  if file.text?
264
293
  asset[:value] = file.read
265
294
  else
266
295
  asset[:attachment] = Base64.encode64(file.read)
267
296
  end
268
297
 
269
- _status, body, response = api_client.put(
270
- path: "themes/#{@theme.id}/assets.json",
271
- body: JSON.generate(asset: asset)
272
- )
298
+ path = "themes/#{@theme.id}/assets.json"
299
+ req_body = JSON.generate(asset: asset)
273
300
 
274
- update_checksums(body)
301
+ api_client.put(path: path, body: req_body) do |_status, resp_body, response|
302
+ update_checksums(resp_body)
303
+
304
+ file.warnings = resp_body.dig("asset", "warnings")
275
305
 
276
- response
306
+ yield(response) if block_given?
307
+ end
277
308
  end
278
309
 
279
310
  def get(file)
@@ -291,7 +322,7 @@ module ShopifyCLI
291
322
  file.write(body.dig("asset", "value"))
292
323
  end
293
324
 
294
- response
325
+ yield(response)
295
326
  end
296
327
 
297
328
  def delete(file)
@@ -302,7 +333,7 @@ module ShopifyCLI
302
333
  })
303
334
  )
304
335
 
305
- response
336
+ yield(response)
306
337
  end
307
338
 
308
339
  def union_merge(file)
@@ -311,11 +342,11 @@ module ShopifyCLI
311
342
  query: URI.encode_www_form("asset[key]" => file.relative_path),
312
343
  )
313
344
 
314
- return response unless file.text?
345
+ return yield(response) unless file.text?
315
346
 
316
347
  remote_content = body.dig("asset", "value")
317
348
 
318
- return response if remote_content.nil?
349
+ return yield(response) if remote_content.nil?
319
350
 
320
351
  content = Merger.union_merge(file, remote_content)
321
352
 
@@ -323,7 +354,7 @@ module ShopifyCLI
323
354
 
324
355
  enqueue(:update, file)
325
356
 
326
- response
357
+ yield(response)
327
358
  end
328
359
 
329
360
  def update_checksums(api_response)
@@ -335,19 +366,29 @@ module ShopifyCLI
335
366
  checksums.reject_duplicated_checksums!
336
367
  end
337
368
 
338
- def parse_api_errors(exception)
339
- parsed_body = JSON.parse(exception&.response&.body)
340
- message = parsed_body.dig("errors", "asset") || parsed_body["message"] || exception.message
369
+ def parse_api_errors(operation, exception)
370
+ parsed_body = if exception&.response&.is_a?(Hash)
371
+ exception&.response&.[](:body)
372
+ else
373
+ JSON.parse(exception&.response&.body)
374
+ end
375
+
376
+ errors = parsed_body.dig("errors") # either nil or another type
377
+ errors = errors.dig("asset") if errors&.is_a?(Hash)
378
+
379
+ message = errors || parsed_body["message"] || exception.message
341
380
  # Truncate to first lines
342
381
  [message].flatten.map { |m| m.split("\n", 2).first }
343
382
  rescue JSON::ParserError
344
383
  [exception.message]
384
+ rescue StandardError => e
385
+ ["The asset #{operation.file} is could not be synced (cause: #{e.message})."]
345
386
  end
346
387
 
347
388
  def backoff_if_near_limit!(used, limit)
348
389
  if used > limit - @threads.size
349
390
  @ctx.debug("Near API call limit, waiting 2 sec…")
350
- @backoff_mutex.synchronize { sleep(2) }
391
+ @backoff_mutex.synchronize { wait(2) }
351
392
  end
352
393
  end
353
394
 
@@ -363,6 +404,15 @@ module ShopifyCLI
363
404
  # Sleeping in the mutex in another thread. Wait for unlock
364
405
  @backoff_mutex.synchronize {} if backingoff?
365
406
  end
407
+
408
+ def handle_operation_error(operation, error)
409
+ error_suffix = ":\n " + parse_api_errors(operation, error).join("\n ")
410
+ report_error(operation, error_suffix)
411
+ end
412
+
413
+ def wait(duration)
414
+ sleep(duration)
415
+ end
366
416
  end
367
417
  end
368
418
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ShopifyCLI
2
4
  module Theme
3
5
  class ThemeAdminAPI
@@ -10,35 +12,36 @@ module ShopifyCLI
10
12
  @shop = shop || get_shop_or_abort
11
13
  end
12
14
 
13
- def get(path:, **args)
14
- rest_request(method: "GET", path: path, **args)
15
+ def get(path:, **args, &block)
16
+ rest_request(method: "GET", path: path, **args, &block)
15
17
  end
16
18
 
17
- def put(path:, **args)
18
- rest_request(method: "PUT", path: path, **args)
19
+ def put(path:, **args, &block)
20
+ rest_request(method: "PUT", path: path, **args, &block)
19
21
  end
20
22
 
21
- def post(path:, **args)
22
- rest_request(method: "POST", path: path, **args)
23
+ def post(path:, **args, &block)
24
+ rest_request(method: "POST", path: path, **args, &block)
23
25
  end
24
26
 
25
- def delete(path:, **args)
26
- rest_request(method: "DELETE", path: path, **args)
27
+ def delete(path:, **args, &block)
28
+ rest_request(method: "DELETE", path: path, **args, &block)
27
29
  end
28
30
 
29
31
  def get_shop_or_abort # rubocop:disable Naming/AccessorMethodName
30
32
  ShopifyCLI::AdminAPI.get_shop_or_abort(@ctx)
31
33
  end
32
34
 
33
- private
34
-
35
35
  def rest_request(**args)
36
- ShopifyCLI::AdminAPI.rest_request(
36
+ status, body, response = ShopifyCLI::AdminAPI.rest_request(
37
37
  @ctx,
38
38
  shop: @shop,
39
39
  api_version: API_VERSION,
40
40
  **args.compact
41
41
  )
42
+ return yield(status, body, response) if block_given?
43
+
44
+ [status, body, response]
42
45
  rescue ShopifyCLI::API::APIRequestForbiddenError,
43
46
  ShopifyCLI::API::APIRequestUnauthorizedError => error
44
47
 
@@ -62,6 +65,8 @@ module ShopifyCLI
62
65
  raise error
63
66
  end
64
67
 
68
+ private
69
+
65
70
  def permission_error
66
71
  ensure_user_error = @ctx.message("theme.ensure_user_error", shop)
67
72
  ensure_user_try_this = @ctx.message("theme.ensure_user_try_this")
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bulk_job"
4
+ require "shopify_cli/thread_pool"
5
+
6
+ module ShopifyCLI
7
+ module Theme
8
+ class ThemeAdminAPIThrottler
9
+ class Bulk
10
+ MAX_BULK_BYTESIZE = 10_485_760 # 10MB
11
+ MAX_BULK_FILES = 20 # files
12
+ QUEUE_TIMEOUT = 0.2 # 200ms
13
+
14
+ attr_accessor :admin_api
15
+
16
+ def initialize(ctx, admin_api, pool_size: 20)
17
+ @ctx = ctx
18
+ @admin_api = admin_api
19
+ @latest_enqueued_at = now
20
+
21
+ @thread_pool = ShopifyCLI::ThreadPool.new(pool_size: pool_size)
22
+
23
+ pool_size.times do
24
+ @thread_pool.schedule(
25
+ BulkJob.new(ctx, self)
26
+ )
27
+ end
28
+
29
+ @put_requests = []
30
+ @mut = Mutex.new
31
+ end
32
+
33
+ def enqueue(put_request)
34
+ @mut.synchronize do
35
+ @latest_enqueued_at = now
36
+ @put_requests << put_request
37
+ end
38
+ end
39
+
40
+ def shutdown
41
+ wait_put_requests
42
+ @thread_pool.shutdown
43
+ end
44
+
45
+ def consume_put_requests
46
+ to_batch = []
47
+ to_batch_size_bytes = 0
48
+ @mut.synchronize do
49
+ # sort requests to perform less retries at the `bulk_job` level
50
+ @put_requests.sort_by! { |r| r.liquid? ? 0 : 1 }
51
+
52
+ is_ready = false
53
+ until is_ready || @put_requests.empty?
54
+ request = @put_requests.first
55
+ if to_batch.empty? && request.size > MAX_BULK_BYTESIZE
56
+ is_ready = true
57
+ to_batch << request
58
+ to_batch_size_bytes += request.size
59
+ @put_requests.shift
60
+ elsif to_batch.size + 1 > MAX_BULK_FILES || to_batch_size_bytes + request.size > MAX_BULK_BYTESIZE
61
+ is_ready = true
62
+ else
63
+ to_batch << request
64
+ to_batch_size_bytes += request.size
65
+ @put_requests.shift
66
+ end
67
+ end
68
+ end
69
+ [to_batch, to_batch_size_bytes]
70
+ end
71
+
72
+ def ready?
73
+ queue_timeout? || bulk_size >= MAX_BULK_FILES || bulk_bytesize >= MAX_BULK_BYTESIZE
74
+ end
75
+
76
+ def bulk_bytesize
77
+ @put_requests.map(&:size).reduce(:+).to_i
78
+ end
79
+
80
+ private
81
+
82
+ def bulk_size
83
+ @put_requests.size
84
+ end
85
+
86
+ def queue_timeout?
87
+ return false if bulk_size.zero?
88
+ elapsed_time = now - @latest_enqueued_at
89
+ elapsed_time > QUEUE_TIMEOUT
90
+ end
91
+
92
+ def wait_put_requests
93
+ sleep(0.2) until @put_requests.empty?
94
+ end
95
+
96
+ def now
97
+ Time.now.to_f
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end