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,765 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'search_engine/client_options'
4
+ require 'search_engine/errors'
5
+ require 'search_engine/observability'
6
+ require 'search_engine/client/request_builder'
7
+ require 'search_engine/client/services'
8
+
9
+ module SearchEngine
10
+ # Thin wrapper on top of the official `typesense` gem.
11
+ #
12
+ # Provides single-search and federated multi-search while enforcing that cache
13
+ # knobs live in URL/common-params and not in per-search request bodies.
14
+ class Client
15
+ # @param config [SearchEngine::Config]
16
+ # @param typesense_client [Object, nil] optional injected Typesense::Client (for tests)
17
+ def initialize(config: SearchEngine.config, typesense_client: nil)
18
+ @config = config
19
+ @typesense = typesense_client
20
+ @services = Services.build(self)
21
+ end
22
+
23
+ # Execute a single search against a collection.
24
+ #
25
+ # @param collection [String] collection name
26
+ # @param params [Hash] Typesense search parameters (q, query_by, etc.)
27
+ # @param url_opts [Hash] URL/common knobs (use_cache, cache_ttl)
28
+ # @return [SearchEngine::Result] Wrapped response with hydrated hits
29
+ # @raise [SearchEngine::Errors::InvalidParams, SearchEngine::Errors::*]
30
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/client`
31
+ # @see `https://typesense.org/docs/latest/api/documents.html#search-document`
32
+ def search(collection:, params:, url_opts: {})
33
+ services.fetch(:search).call(collection: collection, params: params, url_opts: url_opts)
34
+ end
35
+
36
+ # Resolve a logical collection name that might be an alias to the physical collection name.
37
+ #
38
+ # @param logical_name [String]
39
+ # @param timeout_ms [Integer, nil] optional read-timeout override in ms
40
+ # @return [String, nil] physical collection name when alias exists; nil when alias not found
41
+ # @raise [SearchEngine::Errors::*] on network or API errors other than 404
42
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/schema`
43
+ # @see `https://typesense.org/docs/latest/api/aliases.html`
44
+ def resolve_alias(logical_name, timeout_ms: nil)
45
+ services.fetch(:collections).resolve_alias(logical_name, timeout_ms: timeout_ms)
46
+ end
47
+
48
+ # Retrieve the live schema for a physical collection name.
49
+ #
50
+ # @param collection_name [String]
51
+ # @param timeout_ms [Integer, nil] optional read-timeout override in ms
52
+ # @return [Hash, nil] schema hash when found; nil when collection not found (404)
53
+ # @raise [SearchEngine::Errors::*] on other network or API errors
54
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/schema`
55
+ # @see `https://typesense.org/docs/latest/api/collections.html`
56
+ def retrieve_collection_schema(collection_name, timeout_ms: nil)
57
+ services.fetch(:collections).retrieve_schema(collection_name, timeout_ms: timeout_ms)
58
+ end
59
+
60
+ # Upsert an alias to point to the provided physical collection (atomic server-side swap).
61
+ # @param alias_name [String]
62
+ # @param physical_name [String]
63
+ # @return [Hash]
64
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/schema#lifecycle`
65
+ # @see `https://typesense.org/docs/latest/api/aliases.html#upsert-an-alias`
66
+ def upsert_alias(alias_name, physical_name)
67
+ services.fetch(:collections).upsert_alias(alias_name, physical_name)
68
+ end
69
+
70
+ # Create a new physical collection with the given schema.
71
+ # @param schema [Hash] Typesense schema body
72
+ # @return [Hash] created collection schema
73
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/schema#lifecycle`
74
+ # @see `https://typesense.org/docs/latest/api/collections.html#create-a-collection`
75
+ def create_collection(schema)
76
+ services.fetch(:collections).create(schema)
77
+ end
78
+
79
+ # Update a physical collection's schema in-place.
80
+ # @param name [String]
81
+ # @param schema [Hash] Typesense schema body with fields to add/drop
82
+ # @return [Hash] updated collection schema
83
+ # @see `https://typesense.org/docs/latest/api/collections.html#update-collection`
84
+ def update_collection(name, schema)
85
+ services.fetch(:collections).update(name, schema)
86
+ end
87
+
88
+ # Delete a physical collection by name.
89
+ # @param name [String]
90
+ # @param timeout_ms [Integer, nil] optional read-timeout override in ms; when nil, a safer
91
+ # default suitable for destructive operations is used (prefers indexer timeout, with
92
+ # a minimum of 30s).
93
+ # @return [Hash] Typesense delete response
94
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/schema#lifecycle`
95
+ # @see `https://typesense.org/docs/latest/api/collections.html#delete-a-collection`
96
+ def delete_collection(name, timeout_ms: nil)
97
+ effective_timeout_ms = begin
98
+ if timeout_ms&.to_i&.positive?
99
+ timeout_ms.to_i
100
+ else
101
+ # Prefer a longer timeout for potentially long-running delete operations
102
+ idx = begin
103
+ config.indexer&.timeout_ms
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ base = idx.to_i.positive? ? idx.to_i : config.timeout_ms.to_i
108
+ base < 30_000 ? 30_000 : base
109
+ end
110
+ rescue StandardError
111
+ 30_000
112
+ end
113
+ services.fetch(:collections).delete(name, timeout_ms: effective_timeout_ms)
114
+ end
115
+
116
+ # List all collections.
117
+ # @param timeout_ms [Integer, nil] optional read-timeout override in ms
118
+ # @return [Array<Hash>] list of collection metadata
119
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/schema`
120
+ # @see `https://typesense.org/docs/latest/api/collections.html#list-all-collections`
121
+ def list_collections(timeout_ms: nil)
122
+ services.fetch(:collections).list(timeout_ms: timeout_ms)
123
+ end
124
+
125
+ # Perform a server health check.
126
+ # @return [Hash] Typesense health response (symbolized where applicable)
127
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/troubleshooting`
128
+ # @see `https://typesense.org/docs/latest/api/cluster-operations.html#health`
129
+ def health
130
+ services.fetch(:operations).health
131
+ end
132
+
133
+ # Retrieve server metrics (raw output).
134
+ #
135
+ # Returns the unmodified JSON object from the Typesense `/metrics.json`
136
+ # endpoint. Keys are not symbolized to preserve the raw shape.
137
+ #
138
+ # @return [Hash] Raw payload from `/metrics.json`
139
+ # @see `https://typesense.org/docs/latest/api/cluster-operations.html#metrics`
140
+ def metrics
141
+ services.fetch(:operations).metrics
142
+ end
143
+
144
+ # Retrieve server stats (raw output).
145
+ #
146
+ # Returns the unmodified JSON object from the Typesense `/stats.json`
147
+ # endpoint. Keys are not symbolized to preserve the raw shape.
148
+ #
149
+ # @return [Hash] Raw payload from `/stats.json`
150
+ # @see `https://typesense.org/docs/latest/api/cluster-operations.html#stats`
151
+ def stats
152
+ services.fetch(:operations).stats
153
+ end
154
+
155
+ # --- Admin: API Keys ------------------------------------------------------
156
+
157
+ # List API keys configured on the Typesense server.
158
+ #
159
+ # @return [Array<Hash>] list of keys with symbolized fields when possible
160
+ # @see `https://typesense.org/docs/latest/api/api-keys.html#list-keys`
161
+ def list_api_keys
162
+ services.fetch(:operations).list_api_keys
163
+ end
164
+
165
+ # --- Admin: Synonyms ----------------------------------------------------
166
+ # NOTE: We rely on the official client's endpoints; names are mapped here.
167
+
168
+ # @param collection [String]
169
+ # @param id [String]
170
+ # @param terms [Array<String>]
171
+ # @return [Hash]
172
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
173
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#upsert-a-synonym`
174
+ def synonyms_upsert(collection:, id:, terms:)
175
+ admin_resource_request(
176
+ resource_type: :synonyms,
177
+ method: :put,
178
+ collection: collection,
179
+ id: id,
180
+ body_data: Array(terms)
181
+ )
182
+ end
183
+
184
+ # @return [Array<Hash>]
185
+ # @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`
187
+ def synonyms_list(collection:)
188
+ admin_resource_request(
189
+ resource_type: :synonyms,
190
+ method: :get,
191
+ collection: collection
192
+ )
193
+ end
194
+
195
+ # @return [Hash, nil]
196
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
197
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#retrieve-a-synonym`
198
+ def synonyms_get(collection:, id:)
199
+ admin_resource_request(
200
+ resource_type: :synonyms,
201
+ method: :get,
202
+ collection: collection,
203
+ id: id,
204
+ return_nil_on_404: true
205
+ )
206
+ end
207
+
208
+ # @return [Hash]
209
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
210
+ # @see `https://typesense.org/docs/latest/api/synonyms.html#delete-a-synonym`
211
+ def synonyms_delete(collection:, id:)
212
+ admin_resource_request(
213
+ resource_type: :synonyms,
214
+ method: :delete,
215
+ collection: collection,
216
+ id: id
217
+ )
218
+ end
219
+
220
+ # --- Admin: Stopwords ---------------------------------------------------
221
+
222
+ # @param collection [String]
223
+ # @param id [String]
224
+ # @param terms [Array<String>]
225
+ # @return [Hash]
226
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
227
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#upsert-a-stopwords`
228
+ def stopwords_upsert(collection:, id:, terms:)
229
+ admin_resource_request(
230
+ resource_type: :stopwords,
231
+ method: :put,
232
+ collection: collection,
233
+ id: id,
234
+ body_data: Array(terms)
235
+ )
236
+ end
237
+
238
+ # @return [Array<Hash>]
239
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
240
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#list-all-stopwords-of-a-collection`
241
+ def stopwords_list(collection:)
242
+ admin_resource_request(
243
+ resource_type: :stopwords,
244
+ method: :get,
245
+ collection: collection
246
+ )
247
+ end
248
+
249
+ # @return [Hash, nil]
250
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
251
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#retrieve-a-stopword`
252
+ def stopwords_get(collection:, id:)
253
+ admin_resource_request(
254
+ resource_type: :stopwords,
255
+ method: :get,
256
+ collection: collection,
257
+ id: id,
258
+ return_nil_on_404: true
259
+ )
260
+ end
261
+
262
+ # @return [Hash]
263
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/synonyms-stopwords`
264
+ # @see `https://typesense.org/docs/latest/api/stopwords.html#delete-a-stopword`
265
+ def stopwords_delete(collection:, id:)
266
+ admin_resource_request(
267
+ resource_type: :stopwords,
268
+ method: :delete,
269
+ collection: collection,
270
+ id: id
271
+ )
272
+ end
273
+
274
+ # -----------------------------------------------------------------------
275
+
276
+ # Bulk import JSONL documents into a collection using Typesense import API.
277
+ #
278
+ # @param collection [String] physical collection name
279
+ # @param jsonl [String] newline-delimited JSON payload
280
+ # @param action [Symbol, String] one of :upsert, :create, :update (default: :upsert)
281
+ # @return [Object] upstream return (String of JSONL statuses or Array of Hashes depending on gem version)
282
+ # @raise [SearchEngine::Errors::*]
283
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/indexer`
284
+ # @see `https://typesense.org/docs/latest/api/documents.html#import-documents`
285
+ def import_documents(collection:, jsonl:, action: :upsert)
286
+ services.fetch(:documents).import(collection: collection, jsonl: jsonl, action: action)
287
+ end
288
+
289
+ # Delete documents by filter from a collection.
290
+ # @param collection [String] physical collection name
291
+ # @param filter_by [String] Typesense filter string
292
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
293
+ # @return [Hash] response from Typesense client (symbolized)
294
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/indexer#stale-deletes`
295
+ # @see `https://typesense.org/docs/latest/api/documents.html#delete-documents-by-query`
296
+ def delete_documents_by_filter(collection:, filter_by:, timeout_ms: nil)
297
+ services.fetch(:documents).delete_by_filter(collection: collection, filter_by: filter_by, timeout_ms: timeout_ms)
298
+ end
299
+
300
+ # Delete a single document by id from a collection.
301
+ #
302
+ # @param collection [String] physical collection name
303
+ # @param id [String, #to_s] document id
304
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
305
+ # @return [Hash, nil] response from Typesense client (symbolized) or nil when 404
306
+ # @see `https://typesense.org/docs/latest/api/documents.html#delete-a-document`
307
+ def delete_document(collection:, id:, timeout_ms: nil)
308
+ services.fetch(:documents).delete(collection: collection, id: id, timeout_ms: timeout_ms)
309
+ end
310
+
311
+ # Retrieve a single document by id from a collection.
312
+ # @param collection [String]
313
+ # @param id [String, #to_s]
314
+ # @param timeout_ms [Integer, nil]
315
+ # @return [Hash, nil] document hash or nil when 404
316
+ # @see `https://typesense.org/docs/latest/api/documents.html#retrieve-a-document`
317
+ def retrieve_document(collection:, id:, timeout_ms: nil)
318
+ services.fetch(:documents).retrieve(collection: collection, id: id, timeout_ms: timeout_ms)
319
+ end
320
+
321
+ # Partially update a single document by id.
322
+ #
323
+ # @param collection [String] physical collection name
324
+ # @param id [String, #to_s] document id
325
+ # @param fields [Hash] partial fields to update
326
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
327
+ # @return [Hash] response from Typesense client (symbolized)
328
+ # @see `https://typesense.org/docs/latest/api/documents.html#update-a-document`
329
+ def update_document(collection:, id:, fields:, timeout_ms: nil)
330
+ services.fetch(:documents).update(collection: collection, id: id, fields: fields, timeout_ms: timeout_ms)
331
+ end
332
+
333
+ # Partially update documents that match a filter.
334
+ #
335
+ # @param collection [String] physical collection name
336
+ # @param filter_by [String] Typesense filter string
337
+ # @param fields [Hash] partial fields to update
338
+ # @param timeout_ms [Integer, nil] optional read timeout override in ms
339
+ # @return [Hash] response from Typesense client (symbolized)
340
+ # @see `https://typesense.org/docs/latest/api/documents.html#update-documents-by-query`
341
+ def update_documents_by_filter(collection:, filter_by:, fields:, timeout_ms: nil)
342
+ services.fetch(:documents).update_by_filter(collection: collection, filter_by: filter_by, fields: fields,
343
+ timeout_ms: timeout_ms
344
+ )
345
+ end
346
+
347
+ # Create a single document in a collection.
348
+ #
349
+ # @param collection [String] physical collection name
350
+ # @param document [Hash] Typesense document body
351
+ # @return [Hash] created document as returned by Typesense (symbolized)
352
+ # @raise [SearchEngine::Errors::InvalidParams, SearchEngine::Errors::*]
353
+ # @see `https://typesense.org/docs/latest/api/documents.html#create-a-document`
354
+ def create_document(collection:, document:)
355
+ services.fetch(:documents).create(collection: collection, document: document)
356
+ end
357
+
358
+ # Execute a multi-search across multiple collections.
359
+ #
360
+ # @param searches [Array<Hash>] per-entry request bodies produced by Multi#to_payloads
361
+ # @param url_opts [Hash] URL/common knobs (use_cache, cache_ttl)
362
+ # @return [Hash] Raw Typesense multi-search response with key 'results'
363
+ # @raise [SearchEngine::Errors::InvalidParams, SearchEngine::Errors::*]
364
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/multi-search-guide`
365
+ # @see `https://typesense.org/docs/latest/api/#multi-search`
366
+ def multi_search(searches:, url_opts: {})
367
+ services.fetch(:search).multi(searches: searches, url_opts: url_opts)
368
+ end
369
+
370
+ # Clear the Typesense server-side search cache.
371
+ #
372
+ # @return [Hash] response payload from Typesense (symbolized keys)
373
+ # @see `https://typesense.org/docs/latest/api/cluster-operations.html#clear-cache`
374
+ def clear_cache
375
+ services.fetch(:operations).clear_cache
376
+ end
377
+
378
+ private
379
+
380
+ attr_reader :config, :services
381
+
382
+ # Internal helper for synonyms and stopwords CRUD operations.
383
+ #
384
+ # @param resource_type [Symbol] :synonyms or :stopwords
385
+ # @param method [Symbol] HTTP method :get, :put, or :delete
386
+ # @param collection [String] collection name
387
+ # @param id [String, nil] resource id (required for get/put/delete on specific resource)
388
+ # @param body_data [Hash, nil] request body data (for put operations)
389
+ # @param return_nil_on_404 [Boolean] return nil instead of raising on 404 (for get operations)
390
+ # @return [Hash, Array<Hash>, nil] response data (symbolized keys) or nil if 404 and return_nil_on_404 is true
391
+ # @raise [SearchEngine::Errors::Api] on API errors (unless 404 and return_nil_on_404 is true)
392
+ def admin_resource_request(resource_type:, method:, collection:, id: nil, body_data: nil, return_nil_on_404: false)
393
+ c = collection.to_s
394
+ s = id.to_s if id
395
+ ts = typesense
396
+ start = current_monotonic_ms
397
+
398
+ # Build path based on resource type and operation
399
+ path = if s
400
+ # For operations with id: /collections/{c}/synonyms/{id} or /collections/{c}/stopwords/{id}
401
+ path_prefix = case resource_type
402
+ when :synonyms
403
+ Client::RequestBuilder::COLLECTIONS_PREFIX + c + Client::RequestBuilder::SYNONYMS_PREFIX
404
+ when :stopwords
405
+ Client::RequestBuilder::COLLECTIONS_PREFIX + c + Client::RequestBuilder::STOPWORDS_PREFIX
406
+ else
407
+ raise ArgumentError, "Unknown resource_type: #{resource_type.inspect}"
408
+ end
409
+ path_prefix + s
410
+ else
411
+ # For list (get without id): /collections/{c}/synonyms or /collections/{c}/stopwords
412
+ path_suffix = case resource_type
413
+ when :synonyms
414
+ Client::RequestBuilder::SYNONYMS_SUFFIX
415
+ when :stopwords
416
+ Client::RequestBuilder::STOPWORDS_SUFFIX
417
+ else
418
+ raise ArgumentError, "Unknown resource_type: #{resource_type.inspect}"
419
+ end
420
+ Client::RequestBuilder::COLLECTIONS_PREFIX + c + path_suffix
421
+ end
422
+
423
+ # Build request body for put operations
424
+ request_body = if method == :put && body_data
425
+ resource_key = resource_type == :synonyms ? :synonyms : :stopwords
426
+ { resource_key => body_data }
427
+ else
428
+ {}
429
+ end
430
+
431
+ result = with_exception_mapping(method, path, {}, start) do
432
+ execute_admin_resource_request(ts, c, s, resource_type, method, request_body)
433
+ end
434
+ symbolize_keys_deep(result)
435
+ rescue Errors::Api => error
436
+ return nil if return_nil_on_404 && error.status.to_i == 404
437
+
438
+ raise
439
+ ensure
440
+ instrument(method, path, (start ? (current_monotonic_ms - start) : 0.0), {})
441
+ end
442
+
443
+ # Execute the actual Typesense API call for admin resources.
444
+ def execute_admin_resource_request(ts, collection, id, resource_type, method, request_body)
445
+ case method
446
+ when :get
447
+ if id
448
+ ts.collections[collection].public_send(resource_type)[id].retrieve
449
+ else
450
+ ts.collections[collection].public_send(resource_type).retrieve
451
+ end
452
+ when :put
453
+ ts.collections[collection].public_send(resource_type)[id].upsert(request_body)
454
+ when :delete
455
+ ts.collections[collection].public_send(resource_type)[id].delete
456
+ else
457
+ raise ArgumentError, "Unsupported method: #{method.inspect}"
458
+ end
459
+ end
460
+
461
+ def typesense
462
+ @typesense ||= build_typesense_client
463
+ end
464
+
465
+ def typesense_for_import
466
+ import_timeout = begin
467
+ config.indexer&.timeout_ms
468
+ rescue StandardError
469
+ nil
470
+ end
471
+ if import_timeout&.to_i&.positive? && import_timeout.to_i != config.timeout_ms.to_i
472
+ build_typesense_client_with_read_timeout(import_timeout.to_i / 1000.0)
473
+ else
474
+ typesense
475
+ end
476
+ end
477
+
478
+ def build_typesense_client_with_read_timeout(read_timeout_seconds)
479
+ require 'typesense'
480
+
481
+ Typesense::Client.new(
482
+ nodes: build_nodes,
483
+ api_key: config.api_key,
484
+ # typesense-ruby v4.1.0 uses a single connection timeout for both open+read
485
+ connection_timeout_seconds: read_timeout_seconds,
486
+ num_retries: safe_retry_attempts,
487
+ retry_interval_seconds: safe_retry_backoff,
488
+ logger: safe_logger,
489
+ log_level: safe_typesense_log_level
490
+ )
491
+ end
492
+
493
+ def build_typesense_client
494
+ require 'typesense'
495
+
496
+ Typesense::Client.new(
497
+ nodes: build_nodes,
498
+ api_key: config.api_key,
499
+ # Single timeout governs both open/read in typesense-ruby
500
+ connection_timeout_seconds: (config.timeout_ms.to_i / 1000.0),
501
+ num_retries: safe_retry_attempts,
502
+ retry_interval_seconds: safe_retry_backoff,
503
+ logger: safe_logger,
504
+ log_level: safe_typesense_log_level
505
+ )
506
+ rescue StandardError => error
507
+ raise error
508
+ end
509
+
510
+ def build_nodes
511
+ proto =
512
+ begin
513
+ config.protocol.to_s.strip.presence || 'http'
514
+ rescue StandardError
515
+ nil
516
+ end
517
+ [
518
+ {
519
+ host: config.host,
520
+ port: config.port,
521
+ protocol: proto
522
+ }
523
+ ]
524
+ end
525
+
526
+ def safe_logger
527
+ config.logger
528
+ rescue StandardError
529
+ nil
530
+ end
531
+
532
+ def safe_retry_attempts
533
+ r = begin
534
+ config.retries
535
+ rescue StandardError
536
+ nil
537
+ end
538
+ return 0 unless r.is_a?(Hash)
539
+
540
+ v = r[:attempts]
541
+ v = v.to_i if v.respond_to?(:to_i)
542
+ v.is_a?(Integer) && v >= 0 ? v : 0
543
+ end
544
+
545
+ def safe_retry_backoff
546
+ r = begin
547
+ config.retries
548
+ rescue StandardError
549
+ nil
550
+ end
551
+ return 0.0 unless r.is_a?(Hash)
552
+
553
+ v = r[:backoff]
554
+ v = v.to_f if v.respond_to?(:to_f)
555
+ v.is_a?(Float) && v >= 0.0 ? v : 0.0
556
+ end
557
+
558
+ def safe_typesense_log_level
559
+ lvl_sym = begin
560
+ SearchEngine.config.logging.level if SearchEngine.config.respond_to?(:logging) && SearchEngine.config.logging
561
+ rescue StandardError
562
+ nil
563
+ end
564
+ require 'logger'
565
+ mapping = {
566
+ debug: ::Logger::DEBUG,
567
+ info: ::Logger::INFO,
568
+ warn: ::Logger::WARN,
569
+ error: ::Logger::ERROR,
570
+ fatal: ::Logger::FATAL
571
+ }
572
+ resolved = mapping[lvl_sym.to_s.downcase.to_sym] || ::Logger::WARN
573
+ # Clamp noisy Typesense debug unless explicitly enabled via env.
574
+ if resolved == ::Logger::DEBUG && !debug_http_enabled_env?
575
+ ::Logger::INFO
576
+ else
577
+ resolved
578
+ end
579
+ end
580
+
581
+ def debug_http_enabled_env?
582
+ val = ENV['SEARCH_ENGINE_DEBUG_HTTP']
583
+ return false if val.nil?
584
+
585
+ s = val.to_s.strip.downcase
586
+ return false if s.empty?
587
+
588
+ %w[1 true yes on].include?(s)
589
+ rescue StandardError
590
+ false
591
+ end
592
+
593
+ def derive_cache_opts(url_opts)
594
+ merged = ClientOptions.url_options_from_config(config)
595
+ merged[:use_cache] = url_opts[:use_cache] if url_opts.key?(:use_cache) && !url_opts[:use_cache].nil?
596
+ merged[:cache_ttl] = Integer(url_opts[:cache_ttl]) if url_opts.key?(:cache_ttl)
597
+ merged
598
+ end
599
+
600
+ def validate_single!(collection, params)
601
+ unless collection.is_a?(String) && !collection.strip.empty?
602
+ raise Errors::InvalidParams, 'collection must be a non-empty String'
603
+ end
604
+
605
+ raise Errors::InvalidParams, 'params must be a Hash' unless params.is_a?(Hash)
606
+ end
607
+
608
+ def validate_multi!(searches)
609
+ unless searches.is_a?(Array) && searches.all? { |s| s.is_a?(Hash) }
610
+ raise Errors::InvalidParams, 'searches must be an Array of Hashes'
611
+ end
612
+
613
+ searches.each_with_index do |s, idx|
614
+ unless s.key?(:collection) && s[:collection].is_a?(String) && !s[:collection].strip.empty?
615
+ raise Errors::InvalidParams, "searches[#{idx}][:collection] must be a non-empty String"
616
+ end
617
+ end
618
+ end
619
+
620
+ def with_exception_mapping(method, path, cache_params, start_ms)
621
+ yield
622
+ rescue StandardError => error
623
+ map_and_raise(error, method, path, cache_params, start_ms)
624
+ end
625
+
626
+ # Map network and API exceptions into stable SearchEngine errors, with
627
+ # redaction and logging.
628
+ def map_and_raise(error, method, path, cache_params, start_ms)
629
+ duration_ms = current_monotonic_ms - start_ms
630
+
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)
634
+
635
+ # Unmapped error: instrument and re-raise as-is
636
+ instrument(method, path, duration_ms, cache_params, error_class: error.class.name)
637
+ raise error
638
+ end
639
+
640
+ # Check if error is a Typesense API error.
641
+ def api_error?(error)
642
+ error.respond_to?(:http_code) || error.class.name.start_with?('Typesense::Error')
643
+ end
644
+
645
+ # Handle Typesense API errors by converting to SearchEngine::Errors::Api.
646
+ def handle_api_error(error, method, path, cache_params, duration_ms)
647
+ status = if error.respond_to?(:http_code)
648
+ error.http_code
649
+ else
650
+ infer_typesense_status(error)
651
+ end
652
+ body = parse_error_body(error)
653
+ err = Errors::Api.new(
654
+ "typesense api error: #{status}",
655
+ status: status || 500,
656
+ body: body,
657
+ doc: Client::RequestBuilder::DOC_CLIENT_ERRORS,
658
+ details: { http_status: status, body: body.is_a?(String) ? body[0, 120] : body }
659
+ )
660
+ instrument(method, path, duration_ms, cache_params, error_class: err.class.name)
661
+ raise err
662
+ end
663
+
664
+ # 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)
667
+ raise Errors::Timeout.new(
668
+ error.message,
669
+ doc: Client::RequestBuilder::DOC_CLIENT_ERRORS,
670
+ details: { op: method, path: path }
671
+ )
672
+ end
673
+
674
+ # 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)
677
+ raise Errors::Connection.new(
678
+ error.message,
679
+ doc: Client::RequestBuilder::DOC_CLIENT_ERRORS,
680
+ details: { op: method, path: path }
681
+ )
682
+ end
683
+
684
+ def timeout_error?(error)
685
+ error.is_a?(::Timeout::Error) || error.class.name.include?('Timeout')
686
+ end
687
+
688
+ def connection_error?(error)
689
+ return true if error.is_a?(SocketError) || error.is_a?(Errno::ECONNREFUSED) || error.is_a?(Errno::ETIMEDOUT)
690
+ return true if error.class.name.include?('Connection')
691
+
692
+ defined?(OpenSSL::SSL::SSLError) && error.is_a?(OpenSSL::SSL::SSLError)
693
+ end
694
+
695
+ # Infer HTTP status code from typesense-ruby error class names when http_code is unavailable.
696
+ def infer_typesense_status(error)
697
+ klass = error.class.name
698
+ return 404 if klass.include?('ObjectNotFound')
699
+ return 401 if klass.include?('RequestUnauthorized')
700
+ return 403 if klass.include?('RequestForbidden')
701
+ return 400 if klass.include?('RequestMalformed')
702
+ return 409 if klass.include?('ObjectAlreadyExists')
703
+ return 500 if klass.include?('ServerError')
704
+
705
+ 500
706
+ end
707
+
708
+ def instrument(method, path, duration_ms, cache_params, error_class: nil)
709
+ return unless defined?(ActiveSupport::Notifications)
710
+
711
+ ActiveSupport::Notifications.instrument(
712
+ 'search_engine.request',
713
+ method: method,
714
+ path: path,
715
+ duration_ms: duration_ms,
716
+ url_opts: Observability.filtered_url_opts(cache_params),
717
+ error_class: error_class
718
+ )
719
+ end
720
+
721
+ def log_success(method, path, start_ms, cache_params)
722
+ return unless safe_logger
723
+
724
+ elapsed = current_monotonic_ms - start_ms
725
+ msg = +'search_engine '
726
+ msg << method.to_s.upcase
727
+ msg << ' '
728
+ msg << path
729
+ msg << ' completed in '
730
+ msg << elapsed.round(1).to_s
731
+ msg << 'ms'
732
+ msg << ' cache='
733
+ msg << (cache_params[:use_cache] ? 'true' : 'false')
734
+ msg << ' ttl='
735
+ msg << cache_params[:cache_ttl].to_s
736
+ safe_logger.info(msg)
737
+ rescue StandardError
738
+ nil
739
+ end
740
+
741
+ def parse_error_body(error)
742
+ return error.body if error.respond_to?(:body) && error.body
743
+ return error.message if error.respond_to?(:message) && error.message
744
+
745
+ nil
746
+ end
747
+
748
+ def current_monotonic_ms
749
+ SearchEngine::Instrumentation.monotonic_ms
750
+ end
751
+
752
+ def symbolize_keys_deep(obj)
753
+ case obj
754
+ when Hash
755
+ obj.each_with_object({}) do |(k, v), h|
756
+ h[k.to_sym] = symbolize_keys_deep(v)
757
+ end
758
+ when Array
759
+ obj.map { |e| symbolize_keys_deep(e) }
760
+ else
761
+ obj
762
+ end
763
+ end
764
+ end
765
+ end