factorix 0.6.0 → 0.7.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/exe/factorix +17 -0
  4. data/lib/factorix/api/mod_download_api.rb +10 -5
  5. data/lib/factorix/api/mod_portal_api.rb +6 -49
  6. data/lib/factorix/cache/base.rb +116 -0
  7. data/lib/factorix/cache/entry.rb +25 -0
  8. data/lib/factorix/cache/file_system.rb +137 -57
  9. data/lib/factorix/cache/redis.rb +287 -0
  10. data/lib/factorix/cache/s3.rb +388 -0
  11. data/lib/factorix/cli/commands/cache/evict.rb +17 -22
  12. data/lib/factorix/cli/commands/cache/stat.rb +57 -58
  13. data/lib/factorix/cli/commands/download_support.rb +1 -6
  14. data/lib/factorix/cli/commands/mod/download.rb +2 -3
  15. data/lib/factorix/cli/commands/mod/edit.rb +1 -4
  16. data/lib/factorix/cli/commands/mod/image/add.rb +1 -4
  17. data/lib/factorix/cli/commands/mod/image/edit.rb +1 -4
  18. data/lib/factorix/cli/commands/mod/image/list.rb +1 -4
  19. data/lib/factorix/cli/commands/mod/install.rb +2 -3
  20. data/lib/factorix/cli/commands/mod/search.rb +2 -3
  21. data/lib/factorix/cli/commands/mod/show.rb +2 -3
  22. data/lib/factorix/cli/commands/mod/sync.rb +2 -3
  23. data/lib/factorix/cli/commands/mod/update.rb +6 -39
  24. data/lib/factorix/cli/commands/mod/upload.rb +1 -4
  25. data/lib/factorix/cli/commands/portal_support.rb +27 -0
  26. data/lib/factorix/container.rb +27 -13
  27. data/lib/factorix/errors.rb +3 -0
  28. data/lib/factorix/http/cache_decorator.rb +5 -5
  29. data/lib/factorix/info_json.rb +5 -5
  30. data/lib/factorix/portal.rb +3 -2
  31. data/lib/factorix/transfer/downloader.rb +19 -11
  32. data/lib/factorix/version.rb +1 -1
  33. data/lib/factorix.rb +45 -53
  34. data/sig/factorix/api/mod_download_api.rbs +1 -2
  35. data/sig/factorix/cache/base.rbs +28 -0
  36. data/sig/factorix/cache/entry.rbs +14 -0
  37. data/sig/factorix/cache/file_system.rbs +7 -6
  38. data/sig/factorix/cache/redis.rbs +36 -0
  39. data/sig/factorix/cache/s3.rbs +38 -0
  40. data/sig/factorix/errors.rbs +3 -0
  41. data/sig/factorix/portal.rbs +1 -1
  42. metadata +25 -2
@@ -48,7 +48,6 @@ module Factorix
48
48
  def call(caches: nil, all: false, expired: false, older_than: nil, **)
49
49
  validate_options!(all, expired, older_than)
50
50
 
51
- @now = Time.now
52
51
  @older_than_seconds = parse_age(older_than) if older_than
53
52
 
54
53
  cache_names = resolve_cache_names(caches)
@@ -114,25 +113,26 @@ module Factorix
114
113
  # @param expired [Boolean] remove expired entries only
115
114
  # @return [Hash] eviction result with :count and :size
116
115
  private def evict_cache(name, all:, expired:)
117
- config = Factorix.config.cache.public_send(name)
118
- cache_dir = config.dir
119
- ttl = config.ttl
120
-
121
- return {count: 0, size: 0} unless cache_dir.exist?
116
+ cache = Container.resolve(:"#{name}_cache")
122
117
 
123
118
  count = 0
124
119
  size = 0
125
120
 
126
- cache_dir.glob("**/*").each do |path|
127
- next unless path.file?
128
- next if path.extname == ".lock"
121
+ # Collect keys to evict (we can't modify during iteration)
122
+ to_evict = []
123
+ cache.each do |key, entry|
124
+ next unless should_evict?(entry, all:, expired:)
125
+
126
+ to_evict << [key, entry.size]
127
+ end
129
128
 
130
- next unless should_evict?(path, ttl, all:, expired:)
129
+ # Perform eviction
130
+ to_evict.each do |key, entry_size|
131
+ next unless cache.delete(key)
131
132
 
132
- size += path.size
133
- path.delete
134
133
  count += 1
135
- logger.debug("Evicted cache entry", path: path.to_s)
134
+ size += entry_size
135
+ logger.debug("Evicted cache entry", key:)
136
136
  end
137
137
 
138
138
  logger.info("Evicted cache entries", cache: name, count:, size:)
@@ -141,23 +141,18 @@ module Factorix
141
141
 
142
142
  # Determine if a cache entry should be evicted
143
143
  #
144
- # @param path [Pathname] path to cache entry
145
- # @param ttl [Integer, nil] cache TTL
144
+ # @param entry [Cache::Entry] cache entry
146
145
  # @param all [Boolean] remove all entries
147
146
  # @param expired [Boolean] remove expired entries only
148
147
  # @return [Boolean] true if entry should be evicted
149
- private def should_evict?(path, ttl, all:, expired:)
148
+ private def should_evict?(entry, all:, expired:)
150
149
  return true if all
151
150
 
152
- age_seconds = @now - path.mtime
153
-
154
151
  if expired
155
- return false if ttl.nil? # No TTL means never expires
156
-
157
- age_seconds > ttl
152
+ entry.expired?
158
153
  else
159
154
  # --older-than
160
- age_seconds > @older_than_seconds
155
+ entry.age > @older_than_seconds
161
156
  end
162
157
  end
163
158
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry/inflector"
3
4
  require "json"
4
5
 
5
6
  module Factorix
@@ -8,13 +9,12 @@ module Factorix
8
9
  module Cache
9
10
  # Display cache statistics
10
11
  #
11
- # This command outout.puts statistics for all cache stores
12
+ # This command outputs statistics for all cache stores
12
13
  # in a human-readable or JSON format.
13
14
  #
14
15
  # @example
15
16
  # $ factorix cache stat
16
17
  # download:
17
- # Directory: ~/.cache/factorix/download
18
18
  # TTL: unlimited
19
19
  # Entries: 42 / 42 (100.0% valid)
20
20
  # ...
@@ -41,7 +41,6 @@ module Factorix
41
41
  def call(json:, **)
42
42
  logger.debug("Collecting cache statistics")
43
43
 
44
- @now = Time.now
45
44
  cache_names = Factorix.config.cache.values.keys
46
45
  stats = cache_names.to_h {|name| [name, collect_stats(name)] }
47
46
 
@@ -52,52 +51,42 @@ module Factorix
52
51
  end
53
52
  end
54
53
 
54
+ # Collect statistics for a cache
55
+ #
56
+ # @param name [Symbol] cache name
57
+ # @return [Hash] cache statistics
55
58
  private def collect_stats(name)
59
+ cache = Container.resolve(:"#{name}_cache")
56
60
  config = Factorix.config.cache.public_send(name)
57
- cache_dir = config.dir
58
61
 
59
- entries = scan_entries(cache_dir, config.ttl)
62
+ entries = scan_entries(cache)
60
63
 
61
64
  {
62
- directory: cache_dir.to_s,
63
65
  ttl: config.ttl,
64
- max_file_size: config.max_file_size,
65
- compression_threshold: config.compression_threshold,
66
66
  entries: build_entry_stats(entries),
67
67
  size: build_size_stats(entries),
68
68
  age: build_age_stats(entries),
69
- stale_locks: count_stale_locks(cache_dir)
69
+ backend_info: cache.backend_info
70
70
  }
71
71
  end
72
72
 
73
- # Scan cache directory and collect entry information
73
+ # Collect cache entries using the cache interface
74
74
  #
75
- # @param cache_dir [Pathname] cache directory path
76
- # @param ttl [Integer, nil] time-to-live in seconds
77
- # @return [Array<Hash>] array of entry info hashes
78
- private def scan_entries(cache_dir, ttl)
79
- return [] unless cache_dir.exist?
80
-
75
+ # @param cache [Cache::Base] cache instance
76
+ # @return [Array<Cache::Entry>] array of cache entries
77
+ private def scan_entries(cache)
81
78
  entries = []
82
- cache_dir.glob("**/*").each do |path|
83
- next unless path.file?
84
- next if path.extname == ".lock"
85
-
86
- age_seconds = @now - path.mtime
87
- expired = ttl ? age_seconds > ttl : false
88
-
89
- entries << {size: path.size, age: age_seconds, expired:}
90
- end
79
+ cache.each {|_key, entry| entries << entry }
91
80
  entries
92
81
  end
93
82
 
94
83
  # Build entry count statistics
95
84
  #
96
- # @param entries [Array<Hash>] entry info array
85
+ # @param entries [Array<Cache::Entry>] entry array
97
86
  # @return [Hash] entry statistics
98
87
  private def build_entry_stats(entries)
99
88
  total = entries.size
100
- valid = entries.count {|e| !e[:expired] }
89
+ valid = entries.count {|e| !e.expired? }
101
90
  expired = total - valid
102
91
 
103
92
  {total:, valid:, expired:}
@@ -105,37 +94,26 @@ module Factorix
105
94
 
106
95
  # Build size statistics
107
96
  #
108
- # @param entries [Array<Hash>] entry info array
97
+ # @param entries [Array<Cache::Entry>] entry array
109
98
  # @return [Hash] size statistics
110
99
  private def build_size_stats(entries)
111
100
  return {total: 0, avg: 0, min: 0, max: 0} if entries.empty?
112
101
 
113
- sizes = entries.map {|e| e[:size] }
102
+ sizes = entries.map(&:size)
114
103
  {total: sizes.sum, avg: sizes.sum / sizes.size, min: sizes.min, max: sizes.max}
115
104
  end
116
105
 
117
106
  # Build age statistics
118
107
  #
119
- # @param entries [Array<Hash>] entry info array
108
+ # @param entries [Array<Cache::Entry>] entry array
120
109
  # @return [Hash] age statistics
121
110
  private def build_age_stats(entries)
122
111
  return {oldest: nil, newest: nil, avg: nil} if entries.empty?
123
112
 
124
- ages = entries.map {|e| e[:age] }
113
+ ages = entries.map(&:age)
125
114
  {oldest: ages.max, newest: ages.min, avg: ages.sum / ages.size}
126
115
  end
127
116
 
128
- # Count stale lock files
129
- #
130
- # @param cache_dir [Pathname] cache directory path
131
- # @return [Integer] number of stale lock files
132
- private def count_stale_locks(cache_dir)
133
- return 0 unless cache_dir.exist?
134
-
135
- lock_lifetime = Factorix::Cache::FileSystem::LOCK_FILE_LIFETIME
136
- cache_dir.glob("**/*.lock").count {|path| @now - path.mtime > lock_lifetime }
137
- end
138
-
139
117
  # Output statistics in text format (ccache-style)
140
118
  #
141
119
  # @param stats [Hash] statistics for all caches
@@ -153,10 +131,7 @@ module Factorix
153
131
  # @param data [Hash] cache statistics
154
132
  # @return [void]
155
133
  private def output_cache_stats(data)
156
- out.puts " Directory: #{data[:directory]}"
157
134
  out.puts " TTL: #{format_ttl(data[:ttl])}"
158
- out.puts " Max file size: #{format_size(data[:max_file_size])}"
159
- out.puts " Compression: #{format_compression(data[:compression_threshold])}"
160
135
 
161
136
  entries = data[:entries]
162
137
  valid_pct = entries[:total] > 0 ? (Float(entries[:valid]) / entries[:total] * 100) : 0.0
@@ -172,7 +147,43 @@ module Factorix
172
147
  out.puts " Age: -"
173
148
  end
174
149
 
175
- out.puts " Stale locks: #{data[:stale_locks]}"
150
+ output_backend_info(data[:backend_info])
151
+ end
152
+
153
+ INFLECTOR = Dry::Inflector.new do |inflections|
154
+ inflections.acronym("URL")
155
+ end
156
+ private_constant :INFLECTOR
157
+
158
+ # Output backend-specific information
159
+ #
160
+ # @param info [Hash] backend-specific information
161
+ # @return [void]
162
+ private def output_backend_info(info)
163
+ return if info.empty?
164
+
165
+ out.puts " Backend:"
166
+ info.each do |key, value|
167
+ label = INFLECTOR.humanize(key)
168
+ formatted_value = format_backend_value(key, value)
169
+ out.puts " %-20s %s" % [label + ":", formatted_value]
170
+ end
171
+ end
172
+
173
+ # Format a backend info value for display
174
+ #
175
+ # @param key [Symbol] the key name
176
+ # @param value [Object] the value to format
177
+ # @return [String] formatted value
178
+ private def format_backend_value(key, value)
179
+ case key
180
+ when :max_file_size, :compression_threshold
181
+ value.nil? ? "unlimited" : format_size(value)
182
+ when :lock_timeout
183
+ format_duration(value)
184
+ else
185
+ value.to_s
186
+ end
176
187
  end
177
188
 
178
189
  # Format TTL value for display
@@ -182,18 +193,6 @@ module Factorix
182
193
  private def format_ttl(ttl)
183
194
  ttl.nil? ? "unlimited" : format_duration(ttl)
184
195
  end
185
-
186
- # Format compression threshold for display
187
- #
188
- # @param threshold [Integer, nil] compression threshold in bytes
189
- # @return [String] formatted compression setting
190
- private def format_compression(threshold)
191
- case threshold
192
- when nil then "disabled"
193
- when 0 then "enabled (always)"
194
- else "enabled (>= #{format_size(threshold)})"
195
- end
196
- end
197
196
  end
198
197
  end
199
198
  end
@@ -97,18 +97,13 @@ module Factorix
97
97
 
98
98
  futures = targets.map {|target|
99
99
  Concurrent::Future.execute(executor: pool) do
100
- thread_portal = Container[:portal]
101
- thread_downloader = thread_portal.mod_download_api.downloader
102
-
103
100
  presenter = multi_presenter.register(
104
101
  target[:mod].name,
105
102
  title: target[:release].file_name
106
103
  )
107
104
  handler = Progress::DownloadHandler.new(presenter)
108
105
 
109
- thread_downloader.subscribe(handler)
110
- thread_portal.download_mod(target[:release], target[:output_path])
111
- thread_downloader.unsubscribe(handler)
106
+ portal.download_mod(target[:release], target[:output_path], handler:)
112
107
  end
113
108
  }
114
109
 
@@ -10,14 +10,13 @@ module Factorix
10
10
  # Download MOD files from Factorio MOD Portal
11
11
  class Download < Base
12
12
  include DownloadSupport
13
+ include PortalSupport
13
14
  # @!parse
14
- # # @return [Portal]
15
- # attr_reader :portal
16
15
  # # @return [Dry::Logger::Dispatcher]
17
16
  # attr_reader :logger
18
17
  # # @return [Runtime]
19
18
  # attr_reader :runtime
20
- include Import[:portal, :logger, :runtime]
19
+ include Import[:logger, :runtime]
21
20
 
22
21
  desc "Download MOD files from Factorio MOD Portal"
23
22
 
@@ -6,10 +6,7 @@ module Factorix
6
6
  module MOD
7
7
  # Edit MOD metadata on Factorio MOD Portal
8
8
  class Edit < Base
9
- # @!parse
10
- # # @return [Portal]
11
- # attr_reader :portal
12
- include Import[:portal]
9
+ include PortalSupport
13
10
 
14
11
  desc "Edit MOD metadata on Factorio MOD Portal"
15
12
 
@@ -7,10 +7,7 @@ module Factorix
7
7
  module Image
8
8
  # Add an image to a MOD on Factorio MOD Portal
9
9
  class Add < Base
10
- # @!parse
11
- # # @return [Portal]
12
- # attr_reader :portal
13
- include Import[:portal]
10
+ include PortalSupport
14
11
 
15
12
  desc "Add an image to a MOD"
16
13
 
@@ -7,10 +7,7 @@ module Factorix
7
7
  module Image
8
8
  # Edit MOD's image list on Factorio MOD Portal
9
9
  class Edit < Base
10
- # @!parse
11
- # # @return [Portal]
12
- # attr_reader :portal
13
- include Import[:portal]
10
+ include PortalSupport
14
11
 
15
12
  desc "Edit MOD's image list (reorder/remove images)"
16
13
 
@@ -7,10 +7,7 @@ module Factorix
7
7
  module Image
8
8
  # List images for a MOD on Factorio MOD Portal
9
9
  class List < Base
10
- # @!parse
11
- # # @return [Portal]
12
- # attr_reader :portal
13
- include Import[:portal]
10
+ include PortalSupport
14
11
 
15
12
  desc "List images for a MOD"
16
13
 
@@ -14,15 +14,14 @@ module Factorix
14
14
  backup_support!
15
15
 
16
16
  include DownloadSupport
17
+ include PortalSupport
17
18
 
18
19
  # @!parse
19
- # # @return [Portal]
20
- # attr_reader :portal
21
20
  # # @return [Dry::Logger::Dispatcher]
22
21
  # attr_reader :logger
23
22
  # # @return [Factorix::Runtime]
24
23
  # attr_reader :runtime
25
- include Import[:portal, :logger, :runtime]
24
+ include Import[:logger, :runtime]
26
25
 
27
26
  desc "Install MOD(s) from Factorio MOD Portal (downloads to MOD directory and enables)"
28
27
 
@@ -8,12 +8,11 @@ module Factorix
8
8
  module MOD
9
9
  # Search MODs on Factorio MOD Portal
10
10
  class Search < Base
11
+ include PortalSupport
11
12
  # @!parse
12
- # # @return [Portal]
13
- # attr_reader :portal
14
13
  # # @return [Runtime]
15
14
  # attr_reader :runtime
16
- include Import[:portal, :runtime]
15
+ include Import[:runtime]
17
16
 
18
17
  desc "Search MOD(s) on Factorio MOD Portal"
19
18
 
@@ -20,11 +20,10 @@ module Factorix
20
20
  INCOMPATIBLE_MOD_STYLE = TIntMe[:red]
21
21
  private_constant :INCOMPATIBLE_MOD_STYLE
22
22
  # @!parse
23
- # # @return [Portal]
24
- # attr_reader :portal
25
23
  # # @return [Runtime]
26
24
  # attr_reader :runtime
27
- include Import[:portal, :runtime]
25
+ include Import[:runtime]
26
+ include PortalSupport
28
27
 
29
28
  desc "Show MOD details from Factorio MOD Portal"
30
29
 
@@ -14,15 +14,14 @@ module Factorix
14
14
  backup_support!
15
15
 
16
16
  include DownloadSupport
17
+ include PortalSupport
17
18
 
18
19
  # @!parse
19
- # # @return [Portal]
20
- # attr_reader :portal
21
20
  # # @return [Dry::Logger::Dispatcher]
22
21
  # attr_reader :logger
23
22
  # # @return [Factorix::Runtime]
24
23
  # attr_reader :runtime
25
- include Import[:portal, :logger, :runtime]
24
+ include Import[:logger, :runtime]
26
25
 
27
26
  desc "Sync MOD states and startup settings from a save file"
28
27
 
@@ -13,14 +13,14 @@ module Factorix
13
13
  require_game_stopped!
14
14
  backup_support!
15
15
 
16
+ include PortalSupport
17
+ include DownloadSupport
16
18
  # @!parse
17
- # # @return [Portal]
18
- # attr_reader :portal
19
19
  # # @return [Dry::Logger::Dispatcher]
20
20
  # attr_reader :logger
21
21
  # # @return [Factorix::Runtime]
22
22
  # attr_reader :runtime
23
- include Import[:portal, :logger, :runtime]
23
+ include Import[:logger, :runtime]
24
24
 
25
25
  desc "Update MOD(s) to their latest versions"
26
26
 
@@ -141,7 +141,7 @@ module Factorix
141
141
  mod:,
142
142
  mod_info:,
143
143
  current_version:,
144
- latest_release:,
144
+ release: latest_release,
145
145
  output_path: runtime.mod_dir / latest_release.file_name
146
146
  }
147
147
  rescue MODNotOnPortalError
@@ -156,7 +156,7 @@ module Factorix
156
156
  private def show_plan(targets)
157
157
  say "Planning to update #{targets.size} MOD(s):", prefix: :info
158
158
  targets.each do |target|
159
- say " - #{target[:mod]}: #{target[:current_version]} -> #{target[:latest_release].version}"
159
+ say " - #{target[:mod]}: #{target[:current_version]} -> #{target[:release].version}"
160
160
  end
161
161
  end
162
162
 
@@ -176,46 +176,13 @@ module Factorix
176
176
  current_enabled = mod_list.enabled?(mod)
177
177
  mod_list.remove(mod)
178
178
  mod_list.add(mod, enabled: current_enabled)
179
- say "Updated #{mod} to #{target[:latest_release].version}", prefix: :success
179
+ say "Updated #{mod} to #{target[:release].version}", prefix: :success
180
180
  else
181
181
  mod_list.add(mod, enabled: true)
182
182
  say "Added #{mod} to mod-list.json", prefix: :success
183
183
  end
184
184
  end
185
185
  end
186
-
187
- # Download MODs in parallel
188
- #
189
- # @param targets [Array<Hash>] Update targets
190
- # @param jobs [Integer] Number of parallel jobs
191
- # @return [void]
192
- private def download_mods(targets, jobs)
193
- multi_presenter = Progress::MultiPresenter.new(title: "\u{1F4E5}\u{FE0E} Downloads", output: err)
194
-
195
- pool = Concurrent::FixedThreadPool.new(jobs)
196
-
197
- futures = targets.map {|target|
198
- Concurrent::Future.execute(executor: pool) do
199
- thread_portal = Container[:portal]
200
- thread_downloader = thread_portal.mod_download_api.downloader
201
-
202
- presenter = multi_presenter.register(
203
- target[:mod].name,
204
- title: target[:latest_release].file_name
205
- )
206
- handler = Progress::DownloadHandler.new(presenter)
207
-
208
- thread_downloader.subscribe(handler)
209
- thread_portal.download_mod(target[:latest_release], target[:output_path])
210
- thread_downloader.unsubscribe(handler)
211
- end
212
- }
213
-
214
- futures.each(&:wait!)
215
- ensure
216
- pool&.shutdown
217
- pool&.wait_for_termination
218
- end
219
186
  end
220
187
  end
221
188
  end
@@ -6,10 +6,7 @@ module Factorix
6
6
  module MOD
7
7
  # Upload MOD to Factorio MOD Portal (handles both new and update)
8
8
  class Upload < Base
9
- # @!parse
10
- # # @return [Portal]
11
- # attr_reader :portal
12
- include Import[:portal]
9
+ include PortalSupport
13
10
 
14
11
  desc "Upload MOD to Factorio MOD Portal (handles both new and update)"
15
12
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ class CLI
5
+ module Commands
6
+ # Provides lazy Portal resolution for CLI commands
7
+ #
8
+ # This module defers Portal dependency resolution until first use,
9
+ # allowing configuration to be loaded before cache backends are resolved.
10
+ #
11
+ # @example
12
+ # class Show < Base
13
+ # include PortalSupport
14
+ #
15
+ # def call(mod_name:, **)
16
+ # mod_info = portal.get_mod(mod_name)
17
+ # end
18
+ # end
19
+ module PortalSupport
20
+ # Lazily resolve Portal from Container
21
+ #
22
+ # @return [Portal] the portal instance
23
+ private def portal = @portal ||= Container[:portal]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/core"
4
+ require "dry/inflector"
4
5
  require "dry/logger"
5
6
 
6
7
  module Factorix
@@ -13,12 +14,28 @@ module Factorix
13
14
  class Container
14
15
  extend Dry::Core::Container::Mixin
15
16
 
16
- # Some items are registered with memoize: false to support independent event handlers
17
+ INFLECTOR = Dry::Inflector.new do |inflections|
18
+ inflections.uncountable("redis")
19
+ end
20
+ private_constant :INFLECTOR
21
+
22
+ # Build a cache instance from configuration.
23
+ #
24
+ # @param cache_type [Symbol] cache type (:download, :api, :info_json)
25
+ # @param config [Dry::Configurable::Config] cache configuration
26
+ # @return [Cache::Base] cache instance
27
+ def self.build_cache(cache_type, config)
28
+ backend = config.backend
29
+ backend_config = config.public_send(backend).to_h
30
+ backend_class = Cache.const_get(INFLECTOR.classify(backend.to_s))
31
+ backend_class.new(cache_type:, ttl: config.ttl, **backend_config)
32
+ end
33
+ private_class_method :build_cache
34
+
35
+ # :downloader is registered with memoize: false to support independent event handlers
17
36
  # for each parallel download task (e.g., progress tracking).
18
- # Items registered with memoize: false:
19
- # - :downloader (event handlers for progress tracking)
20
- # - :mod_download_api (contains :downloader)
21
- # - :portal (contains :mod_download_api)
37
+ # MODDownloadAPI resolves :downloader lazily per download call, allowing
38
+ # :mod_download_api and :portal to be safely memoized.
22
39
 
23
40
  # Register runtime detector
24
41
  register(:runtime, memoize: true) do
@@ -48,20 +65,17 @@ module Factorix
48
65
 
49
66
  # Register download cache
50
67
  register(:download_cache, memoize: true) do
51
- c = Factorix.config.cache.download
52
- Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
68
+ build_cache(:download, Factorix.config.cache.download)
53
69
  end
54
70
 
55
71
  # Register API cache (with compression for JSON responses)
56
72
  register(:api_cache, memoize: true) do
57
- c = Factorix.config.cache.api
58
- Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
73
+ build_cache(:api, Factorix.config.cache.api)
59
74
  end
60
75
 
61
76
  # Register info.json cache (for MOD metadata from ZIP files)
62
77
  register(:info_json_cache, memoize: true) do
63
- c = Factorix.config.cache.info_json
64
- Cache::FileSystem.new(c.dir, **c.to_h.except(:dir))
78
+ build_cache(:info_json, Factorix.config.cache.info_json)
65
79
  end
66
80
 
67
81
  # Register base HTTP client
@@ -118,7 +132,7 @@ module Factorix
118
132
  end
119
133
 
120
134
  # Register MOD Download API client
121
- register(:mod_download_api, memoize: false) do
135
+ register(:mod_download_api, memoize: true) do
122
136
  API::MODDownloadAPI.new
123
137
  end
124
138
 
@@ -134,7 +148,7 @@ module Factorix
134
148
  end
135
149
 
136
150
  # Register portal (high-level API wrapper)
137
- register(:portal, memoize: false) do
151
+ register(:portal, memoize: true) do
138
152
  Portal.new
139
153
  end
140
154
  end
@@ -48,6 +48,9 @@ module Factorix
48
48
  class HTTPNotFoundError < HTTPClientError; end
49
49
  class HTTPServerError < HTTPError; end
50
50
 
51
+ # Cache lock timeout errors
52
+ class LockTimeoutError < InfrastructureError; end
53
+
51
54
  # Digest verification errors
52
55
  class DigestMismatchError < InfrastructureError; end
53
56