search-engine-for-typesense 1.0.1 → 30.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 771a89c8b446dc9b6ed5ca4f3d9baf0d03287c57df03547afb90d450339a0f3a
4
- data.tar.gz: 19fe80cf4f933baa88111bc9dec607d97a8bb834b88955f4392634dbd3ab2305
3
+ metadata.gz: edc1b2db8a930d829b052ce9e414921cbd78c333111596c754fabe9ce443e26c
4
+ data.tar.gz: bcd25036e9eb8202bc813decea0c7c8904f9b7565e5cfb254a2d7fb5760dd473
5
5
  SHA512:
6
- metadata.gz: 4db81fdc22c60c5de4f47fc1cf3e12c27ceeb6eb50a5189f801d4be0ba5b9497b12ddafb7fdb3d10f051b611ce451c5afac216f9a08ebf4ae819a81edf6372ef
7
- data.tar.gz: 3dd6c797700d0e778be77a437d475c622dc82a0248fbd0c011a784e02eeb355fc38988b3a1d5bec8613b25fbe8013788a89bdc3d68fe5503da1f12de869cc60d
6
+ metadata.gz: 275bdb5de35a2c8fb31f12a187231713756f5bf381b32d1bf255d6830b0feadc521b8f1aacdb2f2069b519e3ce5f6e5b793bb1a6ee9b79ceaded017afce4259e
7
+ data.tar.gz: 3401b22b7440aaa90040004128cc5dea5ff76355505471f1752d9b91909acdbff120842904295fd014a349d3f785eb18c774f8e94583cd4887b651078fac6143
data/README.md CHANGED
@@ -9,6 +9,12 @@ Mountless Rails::Engine for [Typesense](https://typesense.org). Expressive Relat
9
9
  > [!NOTE]
10
10
  > This project is not affiliated with [Typesense](https://typesense.org) and is a wrapper for the [`typesense` gem](https://github.com/typesense/typesense-ruby).
11
11
 
12
+ ## Versioning
13
+
14
+ The gem version mirrors the Typesense server major/minor it targets. Patch releases are reserved for gem-only fixes and enhancements.
15
+
16
+ Example: `30.1.x` targets Typesense `30.1`.
17
+
12
18
  ## Quickstart
13
19
 
14
20
  ```ruby
@@ -142,7 +148,7 @@ See [Docs Style Guide](https://nikita-shkoda.mintlify.app/projects/search-engine
142
148
  <!-- Badge references (placeholders) -->
143
149
  [ci-badge]: https://img.shields.io/github/actions/workflow/status/lstpsche/search-engine-for-typesense/ci.yml?branch=main
144
150
  [ci-url]: #
145
- [gem-badge]: https://img.shields.io/gem/v/search-engine-for-typesense.svg?label=gem
151
+ [gem-badge]: https://badge.fury.io/rb/search-engine-for-typesense.svg?icon=si%3Arubygems
146
152
  [gem-url]: https://rubygems.org/gems/search-engine-for-typesense
147
153
  [docs-badge]: https://img.shields.io/badge/docs-index-blue
148
154
  [docs-url]: https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/index
@@ -56,7 +56,7 @@ module SearchEngine
56
56
 
57
57
  # Fallback: attempt a generic call via low-level typesense endpoint access if available.
58
58
  # This keeps adapter permissive for future endpoints without adding Faraday here.
59
- raise ArgumentError, "Unsupported request path for adapter: #{request.method} #{request.path}"
59
+ raise ArgumentError, "Unsupported request path for adapter: #{request.http_method} #{request.path}"
60
60
  end
61
61
  end
62
62
  end
@@ -41,8 +41,8 @@ module SearchEngine
41
41
  client.__send__(:with_exception_mapping, *args, &block)
42
42
  end
43
43
 
44
- def instrument(*args)
45
- client.__send__(:instrument, *args)
44
+ def instrument(*args, **kwargs)
45
+ client.__send__(:instrument, *args, **kwargs)
46
46
  end
47
47
 
48
48
  def log_success(*args)
@@ -28,7 +28,7 @@ module SearchEngine
28
28
 
29
29
  raise
30
30
  ensure
31
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {})
31
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
32
32
  end
33
33
 
34
34
  # @param collection_name [String]
@@ -54,7 +54,7 @@ module SearchEngine
54
54
 
55
55
  raise
56
56
  ensure
57
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {})
57
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
58
58
  end
59
59
 
60
60
  # @param alias_name [String]
@@ -72,7 +72,7 @@ module SearchEngine
72
72
 
73
73
  symbolize_keys_deep(result)
74
74
  ensure
75
- instrument(:put, path, (start ? (current_monotonic_ms - start) : 0.0), {})
75
+ instrument(:put, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
76
76
  end
77
77
 
78
78
  # @param schema [Hash]
@@ -88,7 +88,7 @@ module SearchEngine
88
88
 
89
89
  symbolize_keys_deep(result)
90
90
  ensure
91
- instrument(:post, path, (start ? (current_monotonic_ms - start) : 0.0), {})
91
+ instrument(:post, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
92
92
  end
93
93
 
94
94
  # @param name [String]
@@ -106,7 +106,7 @@ module SearchEngine
106
106
 
107
107
  symbolize_keys_deep(result)
108
108
  ensure
109
- instrument(:patch, path, (start ? (current_monotonic_ms - start) : 0.0), {})
109
+ instrument(:patch, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
110
110
  end
111
111
 
112
112
  # @param name [String]
@@ -133,7 +133,7 @@ module SearchEngine
133
133
 
134
134
  raise
135
135
  ensure
136
- instrument(:delete, path, (start ? (current_monotonic_ms - start) : 0.0), {})
136
+ instrument(:delete, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
137
137
  end
138
138
 
139
139
  # @return [Array<Hash>]
@@ -153,7 +153,7 @@ module SearchEngine
153
153
 
154
154
  symbolize_keys_deep(result)
155
155
  ensure
156
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {})
156
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
157
157
  end
158
158
  end
159
159
  end
@@ -23,7 +23,7 @@ module SearchEngine
23
23
  ts.collections[collection].documents.import(jsonl, action: action.to_s)
24
24
  end
25
25
 
26
- instrument(:post, path, current_monotonic_ms - start, {})
26
+ instrument(:post, path, current_monotonic_ms - start, {}, request_token: start)
27
27
  result
28
28
  end
29
29
 
@@ -51,7 +51,7 @@ module SearchEngine
51
51
  ts.collections[collection].documents.delete(filter_by: filter_by)
52
52
  end
53
53
 
54
- instrument(:delete, path, current_monotonic_ms - start, {})
54
+ instrument(:delete, path, current_monotonic_ms - start, {}, request_token: start)
55
55
  symbolize_keys_deep(result)
56
56
  end
57
57
 
@@ -84,7 +84,7 @@ module SearchEngine
84
84
 
85
85
  raise
86
86
  ensure
87
- instrument(:delete, path, current_monotonic_ms - start, {}) if defined?(start)
87
+ instrument(:delete, path, current_monotonic_ms - start, {}, request_token: start) if defined?(start)
88
88
  end
89
89
 
90
90
  # @param collection [String]
@@ -112,7 +112,7 @@ module SearchEngine
112
112
  result = with_exception_mapping(:patch, path, {}, start) do
113
113
  ts.collections[collection].documents[s].update(fields)
114
114
  end
115
- instrument(:patch, path, current_monotonic_ms - start, {})
115
+ instrument(:patch, path, current_monotonic_ms - start, {}, request_token: start)
116
116
  symbolize_keys_deep(result)
117
117
  end
118
118
 
@@ -142,7 +142,7 @@ module SearchEngine
142
142
  ts.collections[collection].documents.update(fields, filter_by: filter_by)
143
143
  end
144
144
 
145
- instrument(:patch, path, current_monotonic_ms - start, {})
145
+ instrument(:patch, path, current_monotonic_ms - start, {}, request_token: start)
146
146
  symbolize_keys_deep(result)
147
147
  end
148
148
 
@@ -162,7 +162,7 @@ module SearchEngine
162
162
  typesense.collections[collection].documents.create(document)
163
163
  end
164
164
 
165
- instrument(:post, path, current_monotonic_ms - start, {})
165
+ instrument(:post, path, current_monotonic_ms - start, {}, request_token: start)
166
166
  symbolize_keys_deep(result)
167
167
  end
168
168
 
@@ -196,7 +196,11 @@ module SearchEngine
196
196
 
197
197
  raise
198
198
  ensure
199
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}) if defined?(start)
199
+ if defined?(start)
200
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {},
201
+ request_token: start
202
+ )
203
+ end
200
204
  end
201
205
 
202
206
  private
@@ -15,7 +15,7 @@ module SearchEngine
15
15
 
16
16
  symbolize_keys_deep(result)
17
17
  ensure
18
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {})
18
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
19
19
  end
20
20
 
21
21
  def list_api_keys
@@ -37,7 +37,7 @@ module SearchEngine
37
37
 
38
38
  symbolize_keys_deep(result)
39
39
  ensure
40
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {})
40
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
41
41
  end
42
42
 
43
43
  def clear_cache
@@ -48,7 +48,7 @@ module SearchEngine
48
48
  typesense.operations.perform('cache/clear')
49
49
  end
50
50
 
51
- instrument(:post, path, current_monotonic_ms - start, {})
51
+ instrument(:post, path, current_monotonic_ms - start, {}, request_token: start)
52
52
  symbolize_keys_deep(result)
53
53
  end
54
54
 
@@ -75,7 +75,7 @@ module SearchEngine
75
75
  end
76
76
  end
77
77
  ensure
78
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {})
78
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
79
79
  end
80
80
 
81
81
  # Return raw server statistics from Typesense.
@@ -101,7 +101,7 @@ module SearchEngine
101
101
  end
102
102
  end
103
103
  ensure
104
- instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {})
104
+ instrument(:get, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
105
105
  end
106
106
 
107
107
  private
@@ -21,7 +21,7 @@ module SearchEngine
21
21
 
22
22
  result = instrumented_search(collection, params_obj, cache_params, path, payload, start)
23
23
  duration = current_monotonic_ms - start
24
- instrument(:post, path, duration, cache_params)
24
+ instrument(:post, path, duration, cache_params, request_token: start)
25
25
  log_success(:post, path, start, cache_params)
26
26
 
27
27
  klass = begin
@@ -48,7 +48,7 @@ module SearchEngine
48
48
  typesense.multi_search.perform({ searches: bodies }, common_params: cache_params)
49
49
  end
50
50
 
51
- instrument(:post, path, current_monotonic_ms - start, cache_params)
51
+ instrument(:post, path, current_monotonic_ms - start, cache_params, request_token: start)
52
52
  symbolize_keys_deep(result)
53
53
  end
54
54
 
@@ -129,7 +129,7 @@ module SearchEngine
129
129
 
130
130
  pinned = params[:pinned_hits]
131
131
  hidden = params[:hidden_hits]
132
- tags = params[:override_tags]
132
+ tags = params[:curation_tags] || params[:override_tags]
133
133
 
134
134
  pinned_count =
135
135
  case pinned
@@ -12,6 +12,10 @@ module SearchEngine
12
12
  # Provides single-search and federated multi-search while enforcing that cache
13
13
  # knobs live in URL/common-params and not in per-search request bodies.
14
14
  class Client
15
+ REQUEST_ERROR_INSTRUMENTED_KEY = :__se_request_error_instrumented_queue__
16
+ REQUEST_ERROR_INSTRUMENTED_MAX = 32
17
+ REQUEST_ERROR_INSTRUMENTED_TTL_MS = 60_000.0
18
+
15
19
  # @param config [SearchEngine::Config]
16
20
  # @param typesense_client [Object, nil] optional injected Typesense::Client (for tests)
17
21
  def initialize(config: SearchEngine.config, typesense_client: nil)
@@ -163,7 +167,7 @@ module SearchEngine
163
167
  end
164
168
 
165
169
  # --- Admin: Synonyms ----------------------------------------------------
166
- # NOTE: We rely on the official client's endpoints; names are mapped here.
170
+ # We map per-collection synonym IDs to a dedicated synonym set.
167
171
 
168
172
  # @param collection [String]
169
173
  # @param id [String]
@@ -172,10 +176,10 @@ module SearchEngine
172
176
  # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
173
177
  # @see `https://typesense.org/docs/latest/api/synonyms.html#upsert-a-synonym`
174
178
  def synonyms_upsert(collection:, id:, terms:)
175
- admin_resource_request(
176
- resource_type: :synonyms,
179
+ set = synonym_set_name_for_collection(collection)
180
+ synonym_set_item_request(
177
181
  method: :put,
178
- collection: collection,
182
+ set: set,
179
183
  id: id,
180
184
  body_data: Array(terms)
181
185
  )
@@ -183,23 +187,24 @@ module SearchEngine
183
187
 
184
188
  # @return [Array<Hash>]
185
189
  # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
186
- # @see `https://typesense.org/docs/latest/api/synonyms.html#list-all-synonyms-of-a-collection`
190
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#list-all-synonyms-in-a-synonym-set`
187
191
  def synonyms_list(collection:)
188
- admin_resource_request(
189
- resource_type: :synonyms,
192
+ set = synonym_set_name_for_collection(collection)
193
+ raw = synonym_set_item_request(
190
194
  method: :get,
191
- collection: collection
195
+ set: set
192
196
  )
197
+ extract_synonym_items(raw)
193
198
  end
194
199
 
195
200
  # @return [Hash, nil]
196
201
  # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
197
202
  # @see `https://typesense.org/docs/latest/api/synonyms.html#retrieve-a-synonym`
198
203
  def synonyms_get(collection:, id:)
199
- admin_resource_request(
200
- resource_type: :synonyms,
204
+ set = synonym_set_name_for_collection(collection)
205
+ synonym_set_item_request(
201
206
  method: :get,
202
- collection: collection,
207
+ set: set,
203
208
  id: id,
204
209
  return_nil_on_404: true
205
210
  )
@@ -209,10 +214,10 @@ module SearchEngine
209
214
  # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
210
215
  # @see `https://typesense.org/docs/latest/api/synonyms.html#delete-a-synonym`
211
216
  def synonyms_delete(collection:, id:)
212
- admin_resource_request(
213
- resource_type: :synonyms,
217
+ set = synonym_set_name_for_collection(collection)
218
+ synonym_set_item_request(
214
219
  method: :delete,
215
- collection: collection,
220
+ set: set,
216
221
  id: id
217
222
  )
218
223
  end
@@ -437,7 +442,88 @@ module SearchEngine
437
442
 
438
443
  raise
439
444
  ensure
440
- instrument(method, path, (start ? (current_monotonic_ms - start) : 0.0), {})
445
+ instrument(method, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
446
+ end
447
+
448
+ # Internal helper for synonym set item CRUD.
449
+ #
450
+ # @param method [Symbol]
451
+ # @param set [String] synonym set name
452
+ # @param id [String, nil] synonym id
453
+ # @param body_data [Array<String>, nil]
454
+ # @param return_nil_on_404 [Boolean]
455
+ # @return [Hash, Array<Hash>, nil]
456
+ def synonym_set_item_request(method:, set:, id: nil, body_data: nil, return_nil_on_404: false)
457
+ set_name = set.to_s
458
+ sid = id.to_s if id
459
+ start = current_monotonic_ms
460
+
461
+ path = if sid
462
+ "/synonym_sets/#{escape_segment(set_name)}/synonyms/#{escape_segment(sid)}"
463
+ else
464
+ "/synonym_sets/#{escape_segment(set_name)}/synonyms"
465
+ end
466
+
467
+ request_body = if method == :put && body_data
468
+ { synonyms: body_data }
469
+ else
470
+ {}
471
+ end
472
+
473
+ result = with_exception_mapping(method, path, {}, start) do
474
+ execute_synonym_set_request(typesense_api_call, method, path, request_body)
475
+ end
476
+ symbolize_keys_deep(result)
477
+ rescue Errors::Api => error
478
+ return nil if return_nil_on_404 && error.status.to_i == 404
479
+
480
+ raise
481
+ ensure
482
+ instrument(method, path, (start ? (current_monotonic_ms - start) : 0.0), {}, request_token: start)
483
+ end
484
+
485
+ def execute_synonym_set_request(api_call, method, path, request_body)
486
+ raise ArgumentError, 'Typesense client missing configuration' unless api_call
487
+
488
+ case method
489
+ when :get
490
+ api_call.get(path)
491
+ when :put
492
+ api_call.put(path, request_body)
493
+ when :delete
494
+ api_call.delete(path)
495
+ else
496
+ raise ArgumentError, "Unsupported method: #{method.inspect}"
497
+ end
498
+ end
499
+
500
+ def extract_synonym_items(raw)
501
+ return [] if raw.nil?
502
+ return raw if raw.is_a?(Array)
503
+
504
+ if raw.is_a?(Hash)
505
+ list = raw[:synonyms] || raw['synonyms'] || raw[:items] || raw['items'] || raw[:results] || raw['results']
506
+ return Array(list) if list
507
+ end
508
+
509
+ Array(raw)
510
+ end
511
+
512
+ def synonym_set_name_for_collection(collection)
513
+ "#{collection}_synonyms_index"
514
+ end
515
+
516
+ def typesense_api_call
517
+ @typesense_api_call ||= begin
518
+ require 'typesense'
519
+ ts = typesense
520
+ Typesense::ApiCall.new(ts.configuration) if ts.respond_to?(:configuration)
521
+ end
522
+ end
523
+
524
+ def escape_segment(value)
525
+ require 'uri'
526
+ URI.encode_www_form_component(value.to_s)
441
527
  end
442
528
 
443
529
  # Execute the actual Typesense API call for admin resources.
@@ -481,7 +567,7 @@ module SearchEngine
481
567
  Typesense::Client.new(
482
568
  nodes: build_nodes,
483
569
  api_key: config.api_key,
484
- # typesense-ruby v4.1.0 uses a single connection timeout for both open+read
570
+ # typesense-ruby uses a single connection timeout for both open+read
485
571
  connection_timeout_seconds: read_timeout_seconds,
486
572
  num_retries: safe_retry_attempts,
487
573
  retry_interval_seconds: safe_retry_backoff,
@@ -628,12 +714,25 @@ module SearchEngine
628
714
  def map_and_raise(error, method, path, cache_params, start_ms)
629
715
  duration_ms = current_monotonic_ms - start_ms
630
716
 
631
- return handle_api_error(error, method, path, cache_params, duration_ms) if api_error?(error)
632
- return handle_timeout_error(error, method, path, cache_params, duration_ms) if timeout_error?(error)
633
- return handle_connection_error(error, method, path, cache_params, duration_ms) if connection_error?(error)
717
+ if api_error?(error)
718
+ return handle_api_error(
719
+ error, method, path, cache_params, duration_ms, request_token: start_ms
720
+ )
721
+ end
722
+ if timeout_error?(error)
723
+ return handle_timeout_error(
724
+ error, method, path, cache_params, duration_ms, request_token: start_ms
725
+ )
726
+ end
727
+ if connection_error?(error)
728
+ return handle_connection_error(
729
+ error, method, path, cache_params, duration_ms, request_token: start_ms
730
+ )
731
+ end
634
732
 
635
733
  # Unmapped error: instrument and re-raise as-is
636
- instrument(method, path, duration_ms, cache_params, error_class: error.class.name)
734
+ instrument(method, path, duration_ms, cache_params, error_class: error.class.name, request_token: start_ms)
735
+ mark_request_error_instrumented(method, path, start_ms)
637
736
  raise error
638
737
  end
639
738
 
@@ -643,7 +742,7 @@ module SearchEngine
643
742
  end
644
743
 
645
744
  # Handle Typesense API errors by converting to SearchEngine::Errors::Api.
646
- def handle_api_error(error, method, path, cache_params, duration_ms)
745
+ def handle_api_error(error, method, path, cache_params, duration_ms, request_token: nil)
647
746
  status = if error.respond_to?(:http_code)
648
747
  error.http_code
649
748
  else
@@ -657,13 +756,22 @@ module SearchEngine
657
756
  doc: Client::RequestBuilder::DOC_CLIENT_ERRORS,
658
757
  details: { http_status: status, body: body.is_a?(String) ? body[0, 120] : body }
659
758
  )
660
- instrument(method, path, duration_ms, cache_params, error_class: err.class.name)
759
+ instrument(method, path, duration_ms, cache_params, error_class: err.class.name, request_token: request_token)
760
+ mark_request_error_instrumented(method, path, request_token)
661
761
  raise err
662
762
  end
663
763
 
664
764
  # Handle timeout errors by converting to SearchEngine::Errors::Timeout.
665
- def handle_timeout_error(error, method, path, cache_params, duration_ms)
666
- instrument(method, path, duration_ms, cache_params, error_class: Errors::Timeout.name)
765
+ def handle_timeout_error(error, method, path, cache_params, duration_ms, request_token: nil)
766
+ instrument(
767
+ method,
768
+ path,
769
+ duration_ms,
770
+ cache_params,
771
+ error_class: Errors::Timeout.name,
772
+ request_token: request_token
773
+ )
774
+ mark_request_error_instrumented(method, path, request_token)
667
775
  raise Errors::Timeout.new(
668
776
  error.message,
669
777
  doc: Client::RequestBuilder::DOC_CLIENT_ERRORS,
@@ -672,8 +780,16 @@ module SearchEngine
672
780
  end
673
781
 
674
782
  # Handle connection errors by converting to SearchEngine::Errors::Connection.
675
- def handle_connection_error(error, method, path, cache_params, duration_ms)
676
- instrument(method, path, duration_ms, cache_params, error_class: Errors::Connection.name)
783
+ def handle_connection_error(error, method, path, cache_params, duration_ms, request_token: nil)
784
+ instrument(
785
+ method,
786
+ path,
787
+ duration_ms,
788
+ cache_params,
789
+ error_class: Errors::Connection.name,
790
+ request_token: request_token
791
+ )
792
+ mark_request_error_instrumented(method, path, request_token)
677
793
  raise Errors::Connection.new(
678
794
  error.message,
679
795
  doc: Client::RequestBuilder::DOC_CLIENT_ERRORS,
@@ -705,8 +821,9 @@ module SearchEngine
705
821
  500
706
822
  end
707
823
 
708
- def instrument(method, path, duration_ms, cache_params, error_class: nil)
824
+ def instrument(method, path, duration_ms, cache_params, error_class: nil, request_token: nil)
709
825
  return unless defined?(ActiveSupport::Notifications)
826
+ return if error_class.nil? && consume_request_error_instrumented?(method, path, request_token)
710
827
 
711
828
  ActiveSupport::Notifications.instrument(
712
829
  'search_engine.request',
@@ -718,6 +835,44 @@ module SearchEngine
718
835
  )
719
836
  end
720
837
 
838
+ def mark_request_error_instrumented(method, path, request_token = nil)
839
+ return nil if request_token.nil?
840
+
841
+ queue = Thread.current[REQUEST_ERROR_INSTRUMENTED_KEY] ||= []
842
+ now = current_monotonic_ms
843
+ prune_request_error_instrumented_queue!(queue, now)
844
+ queue << { method: method.to_sym, path: path.to_s, token: request_token, at: now }
845
+ queue.shift while queue.size > REQUEST_ERROR_INSTRUMENTED_MAX
846
+ nil
847
+ rescue StandardError
848
+ nil
849
+ end
850
+
851
+ def consume_request_error_instrumented?(method, path, request_token = nil)
852
+ return false if request_token.nil?
853
+
854
+ queue = Thread.current[REQUEST_ERROR_INSTRUMENTED_KEY]
855
+ return false unless queue.is_a?(Array) && !queue.empty?
856
+
857
+ now = current_monotonic_ms
858
+ prune_request_error_instrumented_queue!(queue, now)
859
+ idx = queue.index do |entry|
860
+ entry[:method] == method.to_sym && entry[:path] == path.to_s && entry[:token] == request_token
861
+ end
862
+ return false unless idx
863
+
864
+ queue.delete_at(idx)
865
+ true
866
+ rescue StandardError
867
+ false
868
+ end
869
+
870
+ def prune_request_error_instrumented_queue!(queue, now_ms)
871
+ queue.reject! do |entry|
872
+ now_ms - entry[:at].to_f > REQUEST_ERROR_INSTRUMENTED_TTL_MS
873
+ end
874
+ end
875
+
721
876
  def log_success(method, path, start_ms, cache_params)
722
877
  return unless safe_logger
723
878
 
@@ -500,7 +500,7 @@ module SearchEngine
500
500
  joins = Array(state[:joins]).flatten.compact
501
501
  return relation if joins.empty?
502
502
 
503
- # Guard: sorting or selection on joined fields not supported in v1
503
+ # Guard: sorting or selection on joined fields not supported by client-side join fallback
504
504
  orders = Array(state[:orders]).map(&:to_s)
505
505
  if orders.any? { |o| o.start_with?('$') }
506
506
  raise SearchEngine::Errors::InvalidOption.new(
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'search_engine/indexer/import_response_parser'
4
5
 
5
6
  module SearchEngine
6
7
  class Indexer
@@ -131,62 +132,7 @@ module SearchEngine
131
132
  end
132
133
 
133
134
  def parse_import_response(raw)
134
- return parse_from_string(raw) if raw.is_a?(String)
135
- return parse_from_array(raw) if raw.is_a?(Array)
136
-
137
- [0, 0, []]
138
- end
139
-
140
- def parse_from_string(str)
141
- success = 0
142
- failure = 0
143
- samples = []
144
- str.each_line do |line|
145
- line = line.strip
146
- next if line.empty?
147
-
148
- h = safe_parse_json(line)
149
- unless h
150
- failure += 1
151
- samples << 'invalid-json-line'
152
- next
153
- end
154
-
155
- if truthy?(h['success'] || h[:success])
156
- success += 1
157
- else
158
- failure += 1
159
- msg = h['error'] || h[:error] || h['message'] || h[:message]
160
- samples << msg.to_s[0, 200] if msg
161
- end
162
- end
163
- [success, failure, samples[0, 5]]
164
- end
165
-
166
- def parse_from_array(arr)
167
- success = 0
168
- failure = 0
169
- samples = []
170
- arr.each do |h|
171
- if h.is_a?(Hash) && truthy?(h['success'] || h[:success])
172
- success += 1
173
- else
174
- failure += 1
175
- msg = h.is_a?(Hash) ? (h['error'] || h[:error] || h['message'] || h[:message]) : nil
176
- samples << msg.to_s[0, 200] if msg
177
- end
178
- end
179
- [success, failure, samples[0, 5]]
180
- end
181
-
182
- def safe_parse_json(line)
183
- JSON.parse(line)
184
- rescue StandardError
185
- nil
186
- end
187
-
188
- def truthy?(val)
189
- val == true || val.to_s.downcase == 'true'
135
+ SearchEngine::Indexer::ImportResponseParser.parse(raw)
190
136
  end
191
137
 
192
138
  def monotonic_ms
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module SearchEngine
6
+ class Indexer
7
+ # Shared parser for Typesense import API responses.
8
+ #
9
+ # Accepts the two shapes returned by different versions of the Typesense Ruby
10
+ # client:
11
+ # - String (JSONL status lines)
12
+ # - Array<Hash> (already parsed per-document statuses)
13
+ #
14
+ # Returns a stable tuple:
15
+ # [success_count, failure_count, errors_sample]
16
+ module ImportResponseParser
17
+ MAX_ERROR_SAMPLES = 5
18
+ INVALID_JSON_LINE = 'invalid-json-line'
19
+
20
+ module_function
21
+
22
+ # @param raw [String, Array, Object]
23
+ # @return [Array(Integer, Integer, Array<String>)]
24
+ def parse(raw)
25
+ return parse_from_string(raw) if raw.is_a?(String)
26
+ return parse_from_array(raw) if raw.is_a?(Array)
27
+
28
+ [0, 0, []]
29
+ end
30
+
31
+ def parse_from_string(str)
32
+ success = 0
33
+ failure = 0
34
+ samples = []
35
+
36
+ str.each_line do |line|
37
+ row = line.strip
38
+ next if row.empty?
39
+
40
+ parsed = safe_parse_json(row)
41
+ unless parsed
42
+ failure += 1
43
+ samples << INVALID_JSON_LINE
44
+ next
45
+ end
46
+
47
+ if truthy?(parsed['success'] || parsed[:success])
48
+ success += 1
49
+ else
50
+ failure += 1
51
+ msg = parsed['error'] || parsed[:error] || parsed['message'] || parsed[:message]
52
+ samples << msg.to_s[0, 200] if msg
53
+ end
54
+ end
55
+
56
+ [success, failure, samples[0, MAX_ERROR_SAMPLES]]
57
+ end
58
+ module_function :parse_from_string
59
+
60
+ def parse_from_array(arr)
61
+ success = 0
62
+ failure = 0
63
+ samples = []
64
+
65
+ arr.each do |entry|
66
+ if entry.is_a?(Hash) && truthy?(entry['success'] || entry[:success])
67
+ success += 1
68
+ else
69
+ failure += 1
70
+ msg = entry.is_a?(Hash) ? (entry['error'] || entry[:error] || entry['message'] || entry[:message]) : nil
71
+ samples << msg.to_s[0, 200] if msg
72
+ end
73
+ end
74
+
75
+ [success, failure, samples[0, MAX_ERROR_SAMPLES]]
76
+ end
77
+ module_function :parse_from_array
78
+
79
+ def safe_parse_json(line)
80
+ JSON.parse(line)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+ module_function :safe_parse_json
85
+
86
+ def truthy?(value)
87
+ value == true || value.to_s.downcase == 'true'
88
+ end
89
+ module_function :truthy?
90
+ end
91
+ end
92
+ end
@@ -4,6 +4,7 @@ require 'json'
4
4
  require 'timeout'
5
5
  require 'digest'
6
6
  require 'time'
7
+ require 'search_engine/indexer/import_response_parser'
7
8
 
8
9
  module SearchEngine
9
10
  # Batch importer for streaming JSONL documents into a physical collection.
@@ -520,66 +521,7 @@ module SearchEngine
520
521
  end
521
522
 
522
523
  def parse_import_response(raw)
523
- return parse_from_string(raw) if raw.is_a?(String)
524
- return parse_from_array(raw) if raw.is_a?(Array)
525
-
526
- [0, 0, []]
527
- end
528
-
529
- def parse_from_string(str)
530
- success = 0
531
- failure = 0
532
- samples = []
533
-
534
- str.each_line do |line|
535
- line = line.strip
536
- next if line.empty?
537
-
538
- h = safe_parse_json(line)
539
- unless h
540
- failure += 1
541
- samples << 'invalid-json-line'
542
- next
543
- end
544
-
545
- if truthy?(h['success'] || h[:success])
546
- success += 1
547
- else
548
- failure += 1
549
- msg = h['error'] || h[:error] || h['message'] || h[:message]
550
- samples << msg.to_s[0, 200] if msg
551
- end
552
- end
553
-
554
- [success, failure, samples[0, 5]]
555
- end
556
-
557
- def parse_from_array(arr)
558
- success = 0
559
- failure = 0
560
- samples = []
561
-
562
- arr.each do |h|
563
- if h.is_a?(Hash) && truthy?(h['success'] || h[:success])
564
- success += 1
565
- else
566
- failure += 1
567
- msg = h.is_a?(Hash) ? (h['error'] || h[:error] || h['message'] || h[:message]) : nil
568
- samples << msg.to_s[0, 200] if msg
569
- end
570
- end
571
-
572
- [success, failure, samples[0, 5]]
573
- end
574
-
575
- def safe_parse_json(line)
576
- JSON.parse(line)
577
- rescue StandardError
578
- nil
579
- end
580
-
581
- def truthy?(val)
582
- val == true || val.to_s.downcase == 'true'
524
+ SearchEngine::Indexer::ImportResponseParser.parse(raw)
583
525
  end
584
526
 
585
527
  def safe_error_excerpt(error)
@@ -311,7 +311,7 @@ module SearchEngine
311
311
 
312
312
  lines << " Pinned: #{pinned.join(', ')}" unless pinned.empty?
313
313
  lines << " Hidden: #{hidden.join(', ')}" unless hidden.empty?
314
- lines << " Override tags: #{tags.join(', ')}" unless tags.empty?
314
+ lines << " Curation tags: #{tags.join(', ')}" unless tags.empty?
315
315
  lines << " Filter curated hits: #{fch}" unless fch.nil?
316
316
  lines
317
317
  end
@@ -510,7 +510,7 @@ module SearchEngine
510
510
 
511
511
  params[:pinned_hits] = pinned.join(',') if pinned.any?
512
512
  params[:hidden_hits] = hidden.join(',') if hidden.any?
513
- params[:override_tags] = tags.join(',') if tags.any?
513
+ params[:curation_tags] = tags.join(',') if tags.any?
514
514
  params[:filter_curated_hits] = fch unless fch.nil?
515
515
  # Expose a compact curation meta segment for callers (not sent over HTTP)
516
516
  params[:_curation] = { filter_curated_hits: fch } if cur.key?(:filter_curated_hits)
@@ -145,7 +145,7 @@ module SearchEngine
145
145
 
146
146
  # Set multiple curation knobs in one call.
147
147
  # @return [SearchEngine::Relation]
148
- def curate(pin: nil, hide: nil, override_tags: nil, filter_curated_hits: :__unset__)
148
+ def curate(pin: nil, hide: nil, override_tags: nil, curation_tags: nil, filter_curated_hits: :__unset__)
149
149
  spawn do |s|
150
150
  cur = s[:curation] || { pinned: [], hidden: [], override_tags: [], filter_curated_hits: nil }
151
151
 
@@ -157,7 +157,12 @@ module SearchEngine
157
157
  list = normalize_curation_ids(hide)
158
158
  cur[:hidden] = list.each_with_object([]) { |t, acc| acc << t unless acc.include?(t) }
159
159
  end
160
- cur[:override_tags] = normalize_curation_tags(override_tags) unless override_tags.nil?
160
+ tags_input = if !curation_tags.nil?
161
+ curation_tags
162
+ else
163
+ override_tags
164
+ end
165
+ cur[:override_tags] = normalize_curation_tags(tags_input) unless tags_input.nil?
161
166
  if filter_curated_hits != :__unset__
162
167
  cur[:filter_curated_hits] =
163
168
  filter_curated_hits.nil? ? nil : coerce_boolean_strict(filter_curated_hits, :filter_curated_hits)
@@ -820,7 +825,13 @@ module SearchEngine
820
825
 
821
826
  pinned = normalize_curation_ids(value[:pinned] || value['pinned'])
822
827
  hidden = normalize_curation_ids(value[:hidden] || value['hidden'])
823
- tags = normalize_curation_tags(value[:override_tags] || value['override_tags'])
828
+ raw_tags =
829
+ if value.key?(:curation_tags) || value.key?('curation_tags')
830
+ value[:curation_tags] || value['curation_tags']
831
+ else
832
+ value[:override_tags] || value['override_tags']
833
+ end
834
+ tags = normalize_curation_tags(raw_tags)
824
835
 
825
836
  raw_fch = (value.key?(:filter_curated_hits) ? value[:filter_curated_hits] : value['filter_curated_hits'])
826
837
  fch = raw_fch.nil? ? nil : coerce_boolean_strict(raw_fch, :filter_curated_hits)
@@ -3,5 +3,5 @@
3
3
  module SearchEngine
4
4
  # Current gem version.
5
5
  # @return [String]
6
- VERSION = '1.0.1'
6
+ VERSION = '30.1.0'
7
7
  end
data/lib/search_engine.rb CHANGED
@@ -23,6 +23,7 @@ require 'search_engine/collection_resolver'
23
23
  require 'search_engine/cascade'
24
24
  require 'search_engine/indexer'
25
25
  require 'search_engine/indexer/batch_planner'
26
+ require 'search_engine/indexer/import_response_parser'
26
27
  require 'search_engine/indexer/import_dispatcher'
27
28
  require 'search_engine/indexer/retry_policy'
28
29
  require 'search_engine/indexer/bulk_import'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: search-engine-for-typesense
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 30.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikita Shkoda
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - ">="
46
46
  - !ruby/object:Gem::Version
47
- version: 4.1.0
47
+ version: 5.0.0
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: 4.1.0
54
+ version: 5.0.0
55
55
  description: Rails::Engine providing a thin wrapper around Typesense with idiomatic
56
56
  Rails integration.
57
57
  email:
@@ -148,6 +148,7 @@ files:
148
148
  - lib/search_engine/indexer/batch_planner.rb
149
149
  - lib/search_engine/indexer/bulk_import.rb
150
150
  - lib/search_engine/indexer/import_dispatcher.rb
151
+ - lib/search_engine/indexer/import_response_parser.rb
151
152
  - lib/search_engine/indexer/retry_policy.rb
152
153
  - lib/search_engine/instrumentation.rb
153
154
  - lib/search_engine/joins/guard.rb