search-engine-for-typesense 1.0.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 (139) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +148 -0
  4. data/app/search_engine/search_engine/app_info.rb +11 -0
  5. data/app/search_engine/search_engine/index_partition_job.rb +170 -0
  6. data/lib/generators/search_engine/install/install_generator.rb +20 -0
  7. data/lib/generators/search_engine/install/templates/initializer.rb.tt +230 -0
  8. data/lib/generators/search_engine/model/model_generator.rb +86 -0
  9. data/lib/generators/search_engine/model/templates/model.rb.tt +12 -0
  10. data/lib/search-engine-for-typesense.rb +12 -0
  11. data/lib/search_engine/active_record_syncable.rb +247 -0
  12. data/lib/search_engine/admin/stopwords.rb +125 -0
  13. data/lib/search_engine/admin/synonyms.rb +125 -0
  14. data/lib/search_engine/admin.rb +12 -0
  15. data/lib/search_engine/ast/and.rb +52 -0
  16. data/lib/search_engine/ast/binary_op.rb +75 -0
  17. data/lib/search_engine/ast/eq.rb +19 -0
  18. data/lib/search_engine/ast/group.rb +18 -0
  19. data/lib/search_engine/ast/gt.rb +12 -0
  20. data/lib/search_engine/ast/gte.rb +12 -0
  21. data/lib/search_engine/ast/in.rb +28 -0
  22. data/lib/search_engine/ast/lt.rb +12 -0
  23. data/lib/search_engine/ast/lte.rb +12 -0
  24. data/lib/search_engine/ast/matches.rb +55 -0
  25. data/lib/search_engine/ast/node.rb +176 -0
  26. data/lib/search_engine/ast/not_eq.rb +13 -0
  27. data/lib/search_engine/ast/not_in.rb +24 -0
  28. data/lib/search_engine/ast/or.rb +52 -0
  29. data/lib/search_engine/ast/prefix.rb +51 -0
  30. data/lib/search_engine/ast/raw.rb +41 -0
  31. data/lib/search_engine/ast/unary_op.rb +43 -0
  32. data/lib/search_engine/ast.rb +101 -0
  33. data/lib/search_engine/base/creation.rb +727 -0
  34. data/lib/search_engine/base/deletion.rb +80 -0
  35. data/lib/search_engine/base/display_coercions.rb +36 -0
  36. data/lib/search_engine/base/hydration.rb +312 -0
  37. data/lib/search_engine/base/index_maintenance/cleanup.rb +202 -0
  38. data/lib/search_engine/base/index_maintenance/lifecycle.rb +251 -0
  39. data/lib/search_engine/base/index_maintenance/schema.rb +117 -0
  40. data/lib/search_engine/base/index_maintenance.rb +459 -0
  41. data/lib/search_engine/base/indexing_dsl.rb +255 -0
  42. data/lib/search_engine/base/joins.rb +479 -0
  43. data/lib/search_engine/base/model_dsl.rb +472 -0
  44. data/lib/search_engine/base/presets.rb +43 -0
  45. data/lib/search_engine/base/pretty_printer.rb +315 -0
  46. data/lib/search_engine/base/relation_delegation.rb +42 -0
  47. data/lib/search_engine/base/scopes.rb +113 -0
  48. data/lib/search_engine/base/updating.rb +92 -0
  49. data/lib/search_engine/base.rb +38 -0
  50. data/lib/search_engine/bulk.rb +284 -0
  51. data/lib/search_engine/cache.rb +33 -0
  52. data/lib/search_engine/cascade.rb +531 -0
  53. data/lib/search_engine/cli/doctor.rb +631 -0
  54. data/lib/search_engine/cli/support.rb +217 -0
  55. data/lib/search_engine/cli.rb +222 -0
  56. data/lib/search_engine/client/http_adapter.rb +63 -0
  57. data/lib/search_engine/client/request_builder.rb +92 -0
  58. data/lib/search_engine/client/services/base.rb +74 -0
  59. data/lib/search_engine/client/services/collections.rb +161 -0
  60. data/lib/search_engine/client/services/documents.rb +214 -0
  61. data/lib/search_engine/client/services/operations.rb +152 -0
  62. data/lib/search_engine/client/services/search.rb +190 -0
  63. data/lib/search_engine/client/services.rb +29 -0
  64. data/lib/search_engine/client.rb +765 -0
  65. data/lib/search_engine/client_options.rb +20 -0
  66. data/lib/search_engine/collection_resolver.rb +191 -0
  67. data/lib/search_engine/collections_graph.rb +330 -0
  68. data/lib/search_engine/compiled_params.rb +143 -0
  69. data/lib/search_engine/compiler.rb +383 -0
  70. data/lib/search_engine/config/observability.rb +27 -0
  71. data/lib/search_engine/config/presets.rb +92 -0
  72. data/lib/search_engine/config/selection.rb +16 -0
  73. data/lib/search_engine/config/typesense.rb +48 -0
  74. data/lib/search_engine/config/validators.rb +97 -0
  75. data/lib/search_engine/config.rb +917 -0
  76. data/lib/search_engine/console_helpers.rb +130 -0
  77. data/lib/search_engine/deletion.rb +103 -0
  78. data/lib/search_engine/dispatcher.rb +125 -0
  79. data/lib/search_engine/dsl/parser.rb +582 -0
  80. data/lib/search_engine/engine.rb +167 -0
  81. data/lib/search_engine/errors.rb +290 -0
  82. data/lib/search_engine/filters/sanitizer.rb +189 -0
  83. data/lib/search_engine/hydration/materializers.rb +808 -0
  84. data/lib/search_engine/hydration/selection_context.rb +96 -0
  85. data/lib/search_engine/indexer/batch_planner.rb +76 -0
  86. data/lib/search_engine/indexer/bulk_import.rb +626 -0
  87. data/lib/search_engine/indexer/import_dispatcher.rb +198 -0
  88. data/lib/search_engine/indexer/retry_policy.rb +103 -0
  89. data/lib/search_engine/indexer.rb +747 -0
  90. data/lib/search_engine/instrumentation.rb +308 -0
  91. data/lib/search_engine/joins/guard.rb +202 -0
  92. data/lib/search_engine/joins/resolver.rb +95 -0
  93. data/lib/search_engine/logging/color.rb +78 -0
  94. data/lib/search_engine/logging/format_helpers.rb +92 -0
  95. data/lib/search_engine/logging/partition_progress.rb +53 -0
  96. data/lib/search_engine/logging_subscriber.rb +388 -0
  97. data/lib/search_engine/mapper.rb +785 -0
  98. data/lib/search_engine/multi.rb +286 -0
  99. data/lib/search_engine/multi_result.rb +186 -0
  100. data/lib/search_engine/notifications/compact_logger.rb +675 -0
  101. data/lib/search_engine/observability.rb +162 -0
  102. data/lib/search_engine/operations.rb +58 -0
  103. data/lib/search_engine/otel.rb +227 -0
  104. data/lib/search_engine/partitioner.rb +128 -0
  105. data/lib/search_engine/ranking_plan.rb +118 -0
  106. data/lib/search_engine/registry.rb +158 -0
  107. data/lib/search_engine/relation/compiler.rb +711 -0
  108. data/lib/search_engine/relation/deletion.rb +37 -0
  109. data/lib/search_engine/relation/dsl/filters.rb +624 -0
  110. data/lib/search_engine/relation/dsl/selection.rb +240 -0
  111. data/lib/search_engine/relation/dsl.rb +903 -0
  112. data/lib/search_engine/relation/dx/dry_run.rb +59 -0
  113. data/lib/search_engine/relation/dx/friendly_where.rb +24 -0
  114. data/lib/search_engine/relation/dx.rb +231 -0
  115. data/lib/search_engine/relation/materializers.rb +118 -0
  116. data/lib/search_engine/relation/options.rb +138 -0
  117. data/lib/search_engine/relation/state.rb +274 -0
  118. data/lib/search_engine/relation/updating.rb +44 -0
  119. data/lib/search_engine/relation.rb +623 -0
  120. data/lib/search_engine/result.rb +664 -0
  121. data/lib/search_engine/schema.rb +1083 -0
  122. data/lib/search_engine/sources/active_record_source.rb +185 -0
  123. data/lib/search_engine/sources/base.rb +62 -0
  124. data/lib/search_engine/sources/lambda_source.rb +55 -0
  125. data/lib/search_engine/sources/sql_source.rb +196 -0
  126. data/lib/search_engine/sources.rb +71 -0
  127. data/lib/search_engine/stale_rules.rb +160 -0
  128. data/lib/search_engine/test/minitest_assertions.rb +57 -0
  129. data/lib/search_engine/test/offline_client.rb +134 -0
  130. data/lib/search_engine/test/rspec_matchers.rb +77 -0
  131. data/lib/search_engine/test/stub_client.rb +201 -0
  132. data/lib/search_engine/test.rb +66 -0
  133. data/lib/search_engine/test_autoload.rb +8 -0
  134. data/lib/search_engine/update.rb +35 -0
  135. data/lib/search_engine/version.rb +7 -0
  136. data/lib/search_engine.rb +332 -0
  137. data/lib/tasks/search_engine.rake +501 -0
  138. data/lib/tasks/search_engine_doctor.rake +16 -0
  139. metadata +225 -0
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Shared updating helpers for building filters and updating documents
5
+ # across both the mapper DSL and model-level APIs.
6
+ module Update
7
+ module_function
8
+
9
+ # Update documents by filter string or hash in the physical collection
10
+ # resolved for the given klass and optional partition.
11
+ #
12
+ # @param klass [Class] a SearchEngine::Base subclass
13
+ # @param attributes [Hash] fields to update
14
+ # @param filter [String, nil] Typesense filter string (takes precedence over hash)
15
+ # @param hash [Hash, nil] Hash converted to a filter string via Sanitizer
16
+ # @param into [String, nil] explicit physical collection name override
17
+ # @param partition [Object, nil] partition token for resolvers
18
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
19
+ # @return [Integer] number of updated documents as reported by Typesense
20
+ def update_by(klass:, attributes:, filter: nil, hash: nil, into: nil, partition: nil, timeout_ms: nil)
21
+ raise ArgumentError, 'attributes must be a non-empty Hash' unless attributes.is_a?(Hash) && !attributes.empty?
22
+
23
+ filter_str = SearchEngine::Deletion.build_filter(filter, hash)
24
+ collection = SearchEngine::Deletion.resolve_into(klass: klass, partition: partition, into: into)
25
+
26
+ resp = SearchEngine.client.update_documents_by_filter(
27
+ collection: collection,
28
+ filter_by: filter_str,
29
+ fields: attributes,
30
+ timeout_ms: timeout_ms
31
+ )
32
+ (resp && (resp[:num_updated] || resp[:updated] || resp[:numUpdated])).to_i
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ # Current gem version.
5
+ # @return [String]
6
+ VERSION = '1.0.0'
7
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'search_engine/version'
4
+ require 'search_engine/config'
5
+ require 'search_engine/errors'
6
+ require 'search_engine/registry'
7
+ require 'search_engine/relation'
8
+ require 'search_engine/relation/dx'
9
+ require 'search_engine/base'
10
+ require 'search_engine/result'
11
+ require 'search_engine/filters/sanitizer'
12
+ require 'search_engine/ast'
13
+ require 'search_engine/dsl/parser'
14
+ require 'search_engine/compiler'
15
+ require 'search_engine/multi'
16
+ require 'search_engine/client_options'
17
+ require 'search_engine/client'
18
+ require 'search_engine/multi_result'
19
+ require 'search_engine/observability'
20
+ require 'search_engine/instrumentation'
21
+ require 'search_engine/schema'
22
+ require 'search_engine/collection_resolver'
23
+ require 'search_engine/cascade'
24
+ require 'search_engine/indexer'
25
+ require 'search_engine/indexer/batch_planner'
26
+ require 'search_engine/indexer/import_dispatcher'
27
+ require 'search_engine/indexer/retry_policy'
28
+ require 'search_engine/indexer/bulk_import'
29
+ require 'search_engine/mapper'
30
+ require 'search_engine/sources'
31
+ require 'search_engine/partitioner'
32
+ require 'search_engine/dispatcher'
33
+ require 'search_engine/joins/guard'
34
+ require 'search_engine/admin'
35
+ require 'search_engine/ranking_plan'
36
+ require 'search_engine/hydration/selection_context'
37
+ require 'search_engine/hydration/materializers'
38
+ require 'search_engine/compiled_params'
39
+ require 'search_engine/deletion'
40
+ require 'search_engine/update'
41
+ require 'search_engine/stale_rules'
42
+ require 'search_engine/collections_graph'
43
+ require 'search_engine/bulk'
44
+ require 'search_engine/cache'
45
+ require 'search_engine/operations'
46
+ require 'search_engine/engine'
47
+ require 'search_engine/active_record_syncable'
48
+
49
+ # Top-level namespace for the SearchEngine gem.
50
+ # Provides Typesense integration points for Rails applications.
51
+ module SearchEngine
52
+ class << self
53
+ # Access the singleton configuration instance.
54
+ # @return [SearchEngine::Config]
55
+ def config
56
+ @config ||= Config.new
57
+ end
58
+
59
+ # Configure the engine in a thread-safe manner.
60
+ #
61
+ # @yieldparam c [SearchEngine::Config]
62
+ # @return [SearchEngine::Config]
63
+ def configure
64
+ raise ArgumentError, 'block required' unless block_given?
65
+
66
+ config_mutex.synchronize do
67
+ yield config
68
+ config.client = offline_client if config.respond_to?(:test_mode?) && config.test_mode? && config.client.nil?
69
+ config.validate!
70
+ end
71
+ config
72
+ end
73
+
74
+ # Return the configured client or an offline client in test mode.
75
+ # @param config [SearchEngine::Config, nil] optional configuration override
76
+ # @param use_config_client [Boolean] whether to respect config.client (default: true)
77
+ # @return [SearchEngine::Client, SearchEngine::Test::OfflineClient, Object]
78
+ def client(config: nil, use_config_client: true)
79
+ cfg = config || self.config
80
+ return cfg.client if use_config_client && cfg.respond_to?(:client) && cfg.client
81
+ return offline_client if cfg.respond_to?(:test_mode?) && cfg.test_mode?
82
+
83
+ SearchEngine::Client.new(config: cfg)
84
+ end
85
+
86
+ # Return a memoized no-op client used in test/offline mode.
87
+ # @return [SearchEngine::Test::OfflineClient]
88
+ def offline_client
89
+ require 'search_engine/test'
90
+ @offline_client ||= SearchEngine::Test::OfflineClient.new
91
+ end
92
+
93
+ # Convenience accessor for operational helpers.
94
+ # @return [Module] {SearchEngine::Operations}
95
+ def operations
96
+ Operations
97
+ end
98
+
99
+ # Execute a federated multi-search using the Multi builder.
100
+ #
101
+ # Builds and executes a multi-search request, returning a
102
+ # {SearchEngine::Multi::ResultSet} that maps results back to labels.
103
+ # Enforces the configured {SearchEngine::Config#multi_search_limit} before
104
+ # making any network calls.
105
+ #
106
+ # @param common [Hash] optional params merged into each per-search payload after relation compilation
107
+ # @yieldparam m [SearchEngine::Multi] builder to add labeled relations
108
+ # @return [SearchEngine::Multi::ResultSet]
109
+ # @raise [ArgumentError] when the number of searches exceeds the configured limit
110
+ # @example
111
+ # res = SearchEngine.multi_search(common: { query_by: SearchEngine.config.default_query_by }) do |m|
112
+ # m.add :products, Product.all.where(active: true).per(10)
113
+ # m.add :brands, Brand.all.where('name:~rud').per(5)
114
+ # end
115
+ # res[:products].found
116
+ # @note Emits "search_engine.multi_search" via ActiveSupport::Notifications with
117
+ # payload: { searches_count, labels, http_status, source: :multi }.
118
+ def multi_search(common: {})
119
+ raise ArgumentError, 'block required' unless block_given?
120
+
121
+ builder = SearchEngine::Multi.new
122
+ yield builder
123
+
124
+ labels = builder.labels
125
+ raw = execute_multi_search_internal(builder: builder, common: common, labels: labels, use_custom_client: false)
126
+
127
+ # Typesense client returns symbolized keys; be resilient to both forms.
128
+ # Expect: { results: [ { ... }, ... ] }
129
+ list = Array(raw && (raw[:results] || raw['results']))
130
+ pairs = build_label_result_pairs(list, labels, builder)
131
+
132
+ SearchEngine::Multi::ResultSet.new(pairs)
133
+ end
134
+
135
+ # Execute a federated multi-search and return a MultiResult wrapper.
136
+ #
137
+ # Non-breaking: this is a convenience helper; {.multi_search} remains unchanged
138
+ # and returns {SearchEngine::Multi::ResultSet}.
139
+ #
140
+ # @param common [Hash] optional params merged into each per-search payload after relation compilation
141
+ # @yieldparam m [SearchEngine::Multi] builder to add labeled relations
142
+ # @return [SearchEngine::MultiResult]
143
+ # @raise [ArgumentError] when the number of searches exceeds the configured limit
144
+ # @raise [SearchEngine::Errors::Api] when Typesense returns a non-2xx status
145
+ # @example
146
+ # mr = SearchEngine.multi_search_result(common: { query_by: SearchEngine.config.default_query_by }) do |m|
147
+ # m.add :products, Product.all.per(10)
148
+ # m.add :brands, Brand.all.per(5)
149
+ # end
150
+ # mr[:products].found
151
+ # @note Emits "search_engine.multi_search" via ActiveSupport::Notifications with
152
+ # payload: { searches_count, labels, http_status, source: :multi }.
153
+ def multi_search_result(common: {})
154
+ raise ArgumentError, 'block required' unless block_given?
155
+
156
+ builder = SearchEngine::Multi.new
157
+ yield builder
158
+
159
+ labels = builder.labels
160
+ raw = execute_multi_search_internal(builder: builder, common: common, labels: labels, use_custom_client: false)
161
+
162
+ list = Array(raw && (raw[:results] || raw['results']))
163
+ SearchEngine::MultiResult.new(labels: labels, raw_results: list, klasses: builder.klasses)
164
+ end
165
+
166
+ # Execute a federated multi-search and return the raw response.
167
+ #
168
+ # This helper mirrors {.multi_search} but returns the raw Hash returned by
169
+ # the underlying client. It enforces the configured limit and augments API
170
+ # error messages with label context when possible.
171
+ #
172
+ # @param common [Hash] optional params merged into each per-search payload after relation compilation
173
+ # @yieldparam m [SearchEngine::Multi] builder to add labeled relations
174
+ # @return [Hash] Raw Typesense multi-search response
175
+ # @raise [ArgumentError] when the number of searches exceeds the configured limit
176
+ # @raise [SearchEngine::Errors::Api] when Typesense returns a non-2xx status
177
+ # @note Emits "search_engine.multi_search" via ActiveSupport::Notifications with
178
+ # payload: { searches_count, labels, http_status, source: :multi }.
179
+ def multi_search_raw(common: {})
180
+ raise ArgumentError, 'block required' unless block_given?
181
+
182
+ builder = SearchEngine::Multi.new
183
+ yield builder
184
+
185
+ labels = builder.labels
186
+ execute_multi_search_internal(builder: builder, common: common, labels: labels, use_custom_client: true)
187
+ rescue Errors::Api => error
188
+ raise augment_multi_api_error(error, labels)
189
+ end
190
+
191
+ # Build and render a graph of Typesense collections and their interconnections.
192
+ #
193
+ # Discovers collections and field-level references from Typesense (with a
194
+ # registry fallback) and returns a Hash with nodes, edges, cycles, isolated
195
+ # nodes, and ready-to-print ASCII renderings. The diagram prefers Unicode
196
+ # box-drawing characters and falls back to ASCII when requested.
197
+ #
198
+ # @param style [Symbol] :unicode (default) or :ascii
199
+ # @param width [Integer, nil] maximum diagram width; auto-detected when nil
200
+ # @param client [SearchEngine::Client, nil] optional client instance
201
+ # @return [Hash] { nodes:, edges:, cycles:, isolated:, ascii:, ascii_compact:, stats: { ... } }
202
+ # @example
203
+ # g = SearchEngine.collections_graph
204
+ # puts g[:ascii]
205
+ def collections_graph(style: :unicode, width: nil, client: nil)
206
+ ts_client = client || SearchEngine.client
207
+ SearchEngine::CollectionsGraph.build(client: ts_client, style: style, width: width)
208
+ end
209
+
210
+ private
211
+
212
+ def config_mutex
213
+ @config_mutex ||= Mutex.new
214
+ end
215
+
216
+ # Internal method that executes multi-search request with common setup and error handling.
217
+ # Returns the raw Typesense response.
218
+ #
219
+ # @param builder [SearchEngine::Multi] configured builder instance
220
+ # @param common [Hash] optional params merged into each per-search payload
221
+ # @param labels [Array<Symbol>] ordered labels for the search list
222
+ # @param use_custom_client [Boolean] whether to check config.client for custom client instance
223
+ # @return [Hash] raw Typesense multi-search response
224
+ # @raise [ArgumentError] when the number of searches exceeds the configured limit
225
+ # @raise [SearchEngine::Errors::Api] when Typesense returns a non-2xx status
226
+ def execute_multi_search_internal(builder:, common:, labels:, use_custom_client:)
227
+ count = builder.labels.size
228
+ limit = SearchEngine.config.multi_search_limit
229
+ enforce_multi_limit!(count, limit)
230
+
231
+ payloads = builder.to_payloads(common: common)
232
+ url_opts = SearchEngine::ClientOptions.url_options_from_config(SearchEngine.config)
233
+
234
+ client_obj = SearchEngine.client(use_config_client: use_custom_client)
235
+
236
+ if defined?(ActiveSupport::Notifications)
237
+ se_payload = build_multi_event_payload(count, labels, url_opts)
238
+ begin
239
+ SearchEngine::Instrumentation.instrument('search_engine.multi_search', se_payload) do |ctx|
240
+ client_obj.multi_search(searches: payloads, url_opts: url_opts).tap do
241
+ ctx[:http_status] = 200
242
+ end
243
+ rescue Errors::Api => error
244
+ ctx[:http_status] = error.status
245
+ raise
246
+ end
247
+ rescue Errors::Api => error
248
+ raise augment_multi_api_error(error, labels)
249
+ end
250
+ else
251
+ begin
252
+ client_obj.multi_search(searches: payloads, url_opts: url_opts)
253
+ rescue Errors::Api => error
254
+ raise augment_multi_api_error(error, labels)
255
+ end
256
+ end
257
+ end
258
+
259
+ def enforce_multi_limit!(count, limit)
260
+ return unless count > limit
261
+
262
+ raise ArgumentError,
263
+ "multi_search: #{count} searches exceed limit (#{limit}). " \
264
+ 'Increase `SearchEngine.config.multi_search_limit` if necessary.'
265
+ end
266
+
267
+ def build_multi_event_payload(count, labels, url_opts)
268
+ {
269
+ searches_count: count,
270
+ labels: labels.map(&:to_s),
271
+ http_status: nil,
272
+ source: :multi,
273
+ url_opts: Observability.filtered_url_opts(url_opts)
274
+ }
275
+ end
276
+
277
+ def build_label_result_pairs(list, labels, builder)
278
+ pairs = []
279
+ list.each_with_index do |item, idx|
280
+ label = labels[idx]
281
+ klass = builder.klasses[idx]
282
+ result = SearchEngine::Result.new(item, klass: klass)
283
+ pairs << [label, result]
284
+ end
285
+ pairs
286
+ end
287
+
288
+ # Build an API error with additional label context when possible.
289
+ # @param error [SearchEngine::Errors::Api]
290
+ # @param labels [Array<Symbol>] ordered labels for the search list
291
+ # @return [SearchEngine::Errors::Api]
292
+ def augment_multi_api_error(error, labels)
293
+ body = error.body
294
+ failing_index = nil
295
+ failing_status = nil
296
+
297
+ if body.is_a?(Hash)
298
+ results = body['results'] || body[:results]
299
+ if results.is_a?(Array)
300
+ results.each_with_index do |item, idx|
301
+ status = item.is_a?(Hash) ? (item['status'] || item[:status] || item['code'] || item[:code]) : nil
302
+ next if status.nil? || status.to_i == 200
303
+
304
+ failing_index = idx
305
+ failing_status = status.to_i
306
+ break
307
+ end
308
+ end
309
+ end
310
+
311
+ if failing_index && labels[failing_index]
312
+ label = labels[failing_index]
313
+ msg = "Multi-search failed for label :#{label} (status #{failing_status})."
314
+ return Errors::Api.new(msg, status: error.status, body: error.body)
315
+ end
316
+
317
+ # Fallback: summarize
318
+ codes = []
319
+ if body.is_a?(Hash)
320
+ results = body['results'] || body[:results]
321
+ if results.is_a?(Array)
322
+ results.each do |item|
323
+ codes << (item['status'] || item[:status] || item['code'] || item[:code])
324
+ end
325
+ end
326
+ end
327
+ Errors::Api.new("Multi-search failed (statuses: #{codes.compact.join(', ')})", status: error.status,
328
+ body: error.body
329
+ )
330
+ end
331
+ end
332
+ end