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,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'tmpdir'
5
+ require 'pathname'
6
+
7
+ module SearchEngine
8
+ module Cli
9
+ # Small, pure helpers shared by CLI tasks/commands.
10
+ #
11
+ # Side‑effect free and safe to require in task context.
12
+ # All helpers return strings/values; callers are responsible for printing.
13
+ module Support
14
+ # Common UI constants (kept minimal to avoid behavior drift)
15
+ DOCS_ROOT = 'docs/'
16
+ SEP = ' - '
17
+ BULLET = '-'
18
+ CHECK = '✓'
19
+ WARN = '⚠'
20
+ CROSS = '✖'
21
+
22
+ module_function
23
+
24
+ # --- JSON helpers ------------------------------------------------------
25
+ # @param str [Object]
26
+ # @return [Boolean]
27
+ def json_string?(str)
28
+ s = str.to_s.strip
29
+ return false if s.empty?
30
+
31
+ JSON.parse(s)
32
+ true
33
+ rescue StandardError
34
+ false
35
+ end
36
+
37
+ # Parse JSON safely.
38
+ # @param str [Object]
39
+ # @return [Object, nil] parsed JSON or nil when invalid
40
+ def parse_json_safe(str)
41
+ s = str.to_s
42
+ return nil if s.strip.empty?
43
+
44
+ JSON.parse(s)
45
+ rescue StandardError
46
+ nil
47
+ end
48
+
49
+ # Parse JSON or return original string, without raising.
50
+ # @param str [Object]
51
+ # @return [Object] parsed value or the original string
52
+ def parse_json_or_string(str)
53
+ parsed = parse_json_safe(str)
54
+ return parsed unless parsed.nil?
55
+
56
+ str.to_s
57
+ end
58
+
59
+ # Whether JSON output is requested via FORMAT=json.
60
+ # @return [Boolean]
61
+ def json_output?
62
+ (ENV['FORMAT'] || '').to_s.strip.downcase == 'json'
63
+ end
64
+
65
+ # Returns true when ENV[name] is a truthy flag (1/true/yes/on).
66
+ # @param name [String]
67
+ # @return [Boolean]
68
+ def boolean_env?(name)
69
+ truthy_env?(ENV[name])
70
+ end
71
+
72
+ # --- Console formatting ------------------------------------------------
73
+ # Respect NO_COLOR and TTY.
74
+ # @return [Boolean]
75
+ def color?
76
+ no_color = ENV['NO_COLOR']
77
+ return false if truthy_env?(no_color)
78
+
79
+ $stdout.respond_to?(:tty?) ? $stdout.tty? : false
80
+ end
81
+
82
+ # Whether emoji are enabled (disabled via NO_EMOJI=1).
83
+ # @return [Boolean]
84
+ def emoji?
85
+ return false if truthy_env?(ENV['NO_EMOJI'])
86
+
87
+ true
88
+ end
89
+
90
+ # Heading formatter (no decoration by default to preserve existing output).
91
+ # @param text [String]
92
+ # @return [String]
93
+ def fmt_heading(text)
94
+ text.to_s
95
+ end
96
+
97
+ # Bullet line formatter.
98
+ # @param text [String]
99
+ # @return [String]
100
+ def fmt_bullet(text)
101
+ "#{BULLET} #{text}"
102
+ end
103
+
104
+ # Key/Value formatter (key: value)
105
+ # @param key [String]
106
+ # @param value [Object]
107
+ # @return [String]
108
+ def fmt_kv(key, value)
109
+ "#{key}: #{value}"
110
+ end
111
+
112
+ # Green success line.
113
+ # @param text [String]
114
+ # @return [String]
115
+ def fmt_ok(text)
116
+ base = emoji? ? "#{CHECK} #{text}" : "OK #{text}"
117
+ color? ? colorize(base, 32) : base
118
+ end
119
+
120
+ # Yellow warning line.
121
+ # @param text [String]
122
+ # @return [String]
123
+ def fmt_warn(text)
124
+ base = emoji? ? "#{WARN} #{text}" : "WARN #{text}"
125
+ color? ? colorize(base, 33) : base
126
+ end
127
+
128
+ # Red error line.
129
+ # @param text [String]
130
+ # @return [String]
131
+ def fmt_err(text)
132
+ base = emoji? ? "#{CROSS} #{text}" : "ERROR #{text}"
133
+ color? ? colorize(base, 31) : base
134
+ end
135
+
136
+ # Simple text wrap (hard break by width).
137
+ # @param text [String]
138
+ # @param width [Integer]
139
+ # @return [String]
140
+ def wrap(text, width: 80)
141
+ s = text.to_s
142
+ return s if width.to_i <= 0
143
+
144
+ lines = []
145
+ s.split(/\r?\n/).each do |line|
146
+ lines << line.slice!(0, width) while line.length > width
147
+ lines << line
148
+ end
149
+ lines.join("\n")
150
+ end
151
+
152
+ # Indent text by level (2 spaces per level by default).
153
+ # @param text [String]
154
+ # @param level [Integer]
155
+ # @param spaces [Integer]
156
+ # @return [String]
157
+ def indent(text, level: 1, spaces: 2)
158
+ pad = ' ' * (level.to_i * spaces.to_i)
159
+ text.to_s.split(/\r?\n/).map { |l| pad + l }.join("\n")
160
+ end
161
+
162
+ # --- Path helpers ------------------------------------------------------
163
+ # Expand a path (supporting ~).
164
+ # @param path [String]
165
+ # @return [String]
166
+ def expand(path)
167
+ File.expand_path(path.to_s)
168
+ end
169
+
170
+ # Render a relative path from base (defaults to Dir.pwd).
171
+ # @param path [String]
172
+ # @param base [String]
173
+ # @return [String]
174
+ def rel(path, base: Dir.pwd)
175
+ p = Pathname.new(File.expand_path(path.to_s))
176
+ b = Pathname.new(File.expand_path(base.to_s))
177
+ p.relative_path_from(b).to_s
178
+ rescue ArgumentError
179
+ p.to_s
180
+ end
181
+
182
+ # Safe read file contents; returns nil if missing/unreadable.
183
+ # @param path [String]
184
+ # @return [String, nil]
185
+ def safe_read(path)
186
+ p = path.to_s
187
+ return nil unless File.file?(p)
188
+
189
+ File.read(p)
190
+ rescue StandardError
191
+ nil
192
+ end
193
+
194
+ # Generate a tmp file path (not created).
195
+ # @param prefix [String]
196
+ # @return [String]
197
+ def tmp_path(prefix: 'se')
198
+ t = Time.now.utc.strftime('%Y%m%d%H%M%S')
199
+ rand_s = rand(36 ** 6).to_s(36)
200
+ File.join(Dir.tmpdir, "search_engine-#{prefix}-#{t}-#{rand_s}")
201
+ end
202
+
203
+ # --- Internals ---------------------------------------------------------
204
+ def colorize(text, code)
205
+ "\e[#{code}m#{text}\e[0m"
206
+ end
207
+ private_class_method :colorize
208
+
209
+ def truthy_env?(val)
210
+ return false if val.nil?
211
+
212
+ %w[1 true yes on].include?(val.to_s.strip.downcase)
213
+ end
214
+ private_class_method :truthy_env?
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'search_engine/cli/support'
4
+
5
+ module SearchEngine
6
+ # Internal helpers for operator-facing CLI/Rake tasks.
7
+ #
8
+ # This module is intentionally minimal and only used by task definitions
9
+ # located under `lib/tasks/search_engine.rake`. It avoids changing the
10
+ # library require graph by being required from the Rake file.
11
+ module Cli
12
+ class << self
13
+ # Resolve a collection argument into a model class.
14
+ #
15
+ # Attempts to constantize when the input looks like a class name; falls back
16
+ # to the registry via {SearchEngine.collection_for} for logical identifiers.
17
+ #
18
+ # @param arg [#to_s] collection argument (e.g., "SearchEngine::Product" or "products")
19
+ # @return [Class] subclass of {SearchEngine::Base}
20
+ # @raise [ArgumentError] when resolution fails
21
+ def resolve_collection!(arg)
22
+ input = arg.to_s
23
+ raise ArgumentError, 'collection argument required' if input.strip.empty?
24
+
25
+ klass = try_constantize(input)
26
+ klass ||= safe_collection_for(input)
27
+
28
+ unless klass.ancestors.include?(SearchEngine::Base)
29
+ raise ArgumentError, "#{klass.name} must inherit from SearchEngine::Base"
30
+ end
31
+
32
+ klass
33
+ end
34
+
35
+ # Parse a partition argument into a typed value.
36
+ #
37
+ # Numeric strings are converted to Integer; blank values return nil.
38
+ # Any other value is returned as-is (String).
39
+ #
40
+ # @param arg [Object]
41
+ # @return [Integer, String, nil]
42
+ def parse_partition(arg)
43
+ return nil if arg.nil?
44
+
45
+ str = arg.to_s
46
+ return nil if str.strip.empty? || str.strip.casecmp('nil').zero?
47
+
48
+ return Integer(str) if str.match?(/\A-?\d+\z/)
49
+
50
+ str
51
+ rescue ArgumentError
52
+ str
53
+ end
54
+
55
+ # Run a task with small, structured instrumentation.
56
+ #
57
+ # Emits three events when ActiveSupport::Notifications is available:
58
+ # - `search_engine.cli.started`
59
+ # - `search_engine.cli.finished`
60
+ # - `search_engine.cli.error`
61
+ #
62
+ # @param task [String] task name (e.g., "schema:diff")
63
+ # @param payload [Hash] small JSON-safe payload (e.g., { collection: "products" })
64
+ # @yield executes the task body
65
+ # @return [Object] the block's return value
66
+ def with_task_instrumentation(task, payload = {})
67
+ started = monotonic_ms
68
+ instrument('search_engine.cli.started', payload.merge(task: task))
69
+ result = yield
70
+ duration = (monotonic_ms - started).round(1)
71
+ instrument('search_engine.cli.finished', payload.merge(task: task, duration_ms: duration, status: 'ok'))
72
+ result
73
+ rescue StandardError => error
74
+ duration = (monotonic_ms - started).round(1)
75
+ instrument(
76
+ 'search_engine.cli.error',
77
+ payload.merge(task: task, duration_ms: duration, status: 'error', error_class: error.class.name,
78
+ message_truncated: error.message.to_s[0, 200]
79
+ )
80
+ )
81
+ raise
82
+ end
83
+
84
+ # Resolve the effective dispatch mode from ENV override or config.
85
+ # @param env_value [String, Symbol, nil]
86
+ # @return [Symbol] :inline or :active_job (falls back to :inline if AJ unavailable)
87
+ def resolve_dispatch_mode(env_value)
88
+ val = (env_value || ENV['DISPATCH'] || SearchEngine.config.indexer.dispatch || :inline).to_s.downcase
89
+ case val
90
+ when 'active_job', 'activejob', 'aj'
91
+ return :active_job if defined?(::ActiveJob::Base)
92
+ end
93
+ :inline
94
+ end
95
+
96
+ # Return true when ENV[name] is a truthy flag (1/true/yes/on).
97
+ # @param name [String]
98
+ # @return [Boolean]
99
+ def boolean_env?(name)
100
+ SearchEngine::Cli::Support.boolean_env?(name)
101
+ end
102
+
103
+ # Whether JSON output is requested via FORMAT=json.
104
+ # @return [Boolean]
105
+ def json_output?
106
+ SearchEngine::Cli::Support.json_output?
107
+ end
108
+
109
+ # Build an Enumerator that yields a single mapped documents batch for dry-run preview.
110
+ #
111
+ # @param klass [Class]
112
+ # @param partition [Object, nil]
113
+ # @return [Enumerable<Array<Hash>>>]
114
+ # @raise [ArgumentError] when mapper/source is missing
115
+ def docs_enum_for_first_batch(klass, partition)
116
+ mapper = SearchEngine::Mapper.for(klass)
117
+ raise ArgumentError, "mapper is not defined for #{klass.name}" unless mapper
118
+
119
+ rows_enum = rows_enumerator_for(klass, partition)
120
+ first_rows = next_from_enum(rows_enum)
121
+ first_rows ||= []
122
+
123
+ docs, _report = mapper.map_batch!(first_rows, batch_index: 0)
124
+ [docs]
125
+ end
126
+
127
+ # Resolve the physical collection name to import into, mirroring Indexer semantics.
128
+ #
129
+ # @param klass [Class]
130
+ # @param partition [Object, nil]
131
+ # @param into [String, nil]
132
+ # @return [String]
133
+ def resolve_into!(klass, partition: nil, into: nil)
134
+ return into if into && !into.to_s.strip.empty?
135
+
136
+ resolver = SearchEngine.config.partitioning&.default_into_resolver
137
+ if resolver.respond_to?(:arity)
138
+ case resolver.arity
139
+ when 1
140
+ val = resolver.call(klass)
141
+ return val if val && !val.to_s.strip.empty?
142
+ when 2, -1
143
+ val = resolver.call(klass, partition)
144
+ return val if val && !val.to_s.strip.empty?
145
+ end
146
+ elsif resolver
147
+ val = resolver.call(klass)
148
+ return val if val && !val.to_s.strip.empty?
149
+ end
150
+
151
+ # Fallback to logical name (alias)
152
+ if klass.respond_to?(:collection)
153
+ klass.collection.to_s
154
+ else
155
+ klass.name.to_s
156
+ end
157
+ end
158
+
159
+ # Enumerate partitions if DSL is present; otherwise a single nil partition.
160
+ # @param klass [Class]
161
+ # @return [Enumerable]
162
+ def partitions_for(klass)
163
+ compiled = SearchEngine::Partitioner.for(klass)
164
+ return [nil] unless compiled
165
+
166
+ compiled.partitions
167
+ end
168
+
169
+ private
170
+
171
+ def try_constantize(input)
172
+ return nil unless looks_like_constant?(input)
173
+
174
+ Object.const_get(input)
175
+ rescue NameError
176
+ nil
177
+ end
178
+
179
+ def looks_like_constant?(str)
180
+ str.include?('::') || str[0] =~ /[A-Z]/
181
+ end
182
+
183
+ def safe_collection_for(name)
184
+ SearchEngine.collection_for(name)
185
+ rescue ArgumentError
186
+ raise ArgumentError,
187
+ "Unknown collection '#{name}'. Provide a fully qualified class name or a registered collection."
188
+ end
189
+
190
+ def rows_enumerator_for(klass, partition)
191
+ compiled_partitioner = SearchEngine::Partitioner.for(klass)
192
+ return compiled_partitioner.partition_fetch_enum(partition) if compiled_partitioner
193
+
194
+ dsl = klass.instance_variable_get(:@__mapper_dsl__) if klass.instance_variable_defined?(:@__mapper_dsl__)
195
+ source_def = dsl && dsl[:source]
196
+ unless source_def
197
+ raise ArgumentError, 'No partition_fetch defined and no source adapter provided. Define one in the DSL.'
198
+ end
199
+
200
+ adapter = SearchEngine::Sources.build(source_def[:type], **(source_def[:options] || {}), &source_def[:block])
201
+ adapter.each_batch(partition: partition)
202
+ end
203
+
204
+ def next_from_enum(enum)
205
+ return enum.first if enum.respond_to?(:first)
206
+
207
+ e = enum.to_enum
208
+ e.next
209
+ rescue StopIteration
210
+ nil
211
+ end
212
+
213
+ def instrument(event, payload)
214
+ SearchEngine::Instrumentation.instrument(event, payload) {}
215
+ end
216
+
217
+ def monotonic_ms
218
+ SearchEngine::Instrumentation.monotonic_ms
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Client
5
+ # HttpAdapter is a minimal transport wrapper around the injected Typesense::Client.
6
+ #
7
+ # Provides a very small surface to execute normalized requests produced by
8
+ # {SearchEngine::Client::RequestBuilder}, delegating to the official Typesense
9
+ # client without re-implementing low-level HTTP. It stays transport-only and
10
+ # does not parse or coerce responses beyond returning a simple Response struct.
11
+ #
12
+ # @see SearchEngine::Client::RequestBuilder
13
+ # @see `https://typesense.org/docs/latest/api/`
14
+ class HttpAdapter
15
+ Response = Struct.new(:status, :headers, :body, keyword_init: true)
16
+
17
+ TYPESENSE_API_KEY_HEADER = 'X-TYPESENSE-API-KEY'
18
+
19
+ # Initialize with an injected Typesense client instance.
20
+ # @param typesense_client [Object] an instance compatible with Typesense::Client
21
+ # @return [void]
22
+ def initialize(typesense_client)
23
+ @typesense = typesense_client
24
+ end
25
+
26
+ # Execute a normalized request produced by {SearchEngine::Client::RequestBuilder}.
27
+ #
28
+ # Delegates to the upstream Typesense client where possible, returning a
29
+ # transport-level {Response}. This adapter does not perform parsing or
30
+ # symbolization and intentionally exposes the raw upstream value in
31
+ # {Response#body}.
32
+ #
33
+ # Supported forms (current usage):
34
+ # - POST `/collections/:collection/documents/search` (with `common_params`)
35
+ #
36
+ # @param request [SearchEngine::Client::RequestBuilder::Request] normalized request
37
+ # @return [Response] response wrapper with status, headers and raw body
38
+ # @raise [ArgumentError] when the request path is not supported by this adapter
39
+ # @see `https://typesense.org/docs/latest/api/documents.html#search-document`
40
+ def perform(request)
41
+ method = request.http_method.to_sym
42
+ path = request.path.to_s
43
+ if method == :post && path.match?(%r{\A/collections/[^/]+/documents/search\z})
44
+ collection = path.split('/')[2]
45
+ docs = @typesense.collections[collection].documents
46
+ begin
47
+ params = docs.method(:search).parameters
48
+ supports_common = params.any? { |(kind, _)| %i[key keyreq keyrest].include?(kind) }
49
+ rescue StandardError
50
+ supports_common = false
51
+ end
52
+ raw = supports_common ? docs.search(request.body, common_params: request.params) : docs.search(request.body)
53
+ # The official client returns parsed JSON (Hash). No headers/status exposed here.
54
+ return Response.new(status: 200, headers: {}, body: raw)
55
+ end
56
+
57
+ # Fallback: attempt a generic call via low-level typesense endpoint access if available.
58
+ # This keeps adapter permissive for future endpoints without adding Faraday here.
59
+ raise ArgumentError, "Unsupported request path for adapter: #{request.method} #{request.path}"
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Client
5
+ # RequestBuilder maps compiled params to concrete Typesense requests.
6
+ # It has no network concerns; it only assembles the request object.
7
+ #
8
+ # Produces a normalized {Request} that can be executed by
9
+ # {SearchEngine::Client::HttpAdapter} or directly by the client methods.
10
+ # Sanitizes internal-only keys before encoding to ensure the payload is
11
+ # compatible with the Typesense API.
12
+ #
13
+ # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/client`
14
+ # @see `https://typesense.org/docs/latest/api/`
15
+ class RequestBuilder
16
+ # Normalized request container used by the transport adapter.
17
+ # @!attribute http_method [Symbol] one of :get, :post, :put, :delete
18
+ # @!attribute path [String] absolute path (no host), e.g. "/collections/products/documents/search"
19
+ # @!attribute params [Hash] URL/common params (e.g., { use_cache:, cache_ttl: })
20
+ # @!attribute headers [Hash] per-request headers (adapter may add global ones)
21
+ # @!attribute body [Hash, nil] JSON body as a Ruby Hash
22
+ # @!attribute body_json [String, nil] Deterministic JSON representation of body
23
+ Request = Struct.new(:http_method, :path, :params, :headers, :body, :body_json, keyword_init: true)
24
+
25
+ COLLECTIONS_PREFIX = '/collections/'
26
+ DOCUMENTS_SEARCH_SUFFIX = '/documents/search'
27
+ # Additional endpoint fragments for internal reuse
28
+ COLLECTIONS_ROOT = '/collections'
29
+ DOCUMENTS_SUFFIX = '/documents'
30
+ DOCUMENTS_IMPORT_SUFFIX = '/documents/import'
31
+ ALIASES_PREFIX = '/aliases/'
32
+ SYNONYMS_SUFFIX = '/synonyms'
33
+ SYNONYMS_PREFIX = '/synonyms/'
34
+ STOPWORDS_SUFFIX = '/stopwords'
35
+ STOPWORDS_PREFIX = '/stopwords/'
36
+ HEALTH_PATH = '/health'
37
+
38
+ CONTENT_TYPE_JSON = 'application/json'
39
+ DEFAULT_HEADERS_JSON = { 'Content-Type' => CONTENT_TYPE_JSON }.freeze
40
+
41
+ # Centralized doc links used by client error mapping
42
+ DOC_CLIENT_ERRORS = 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/client#errors'
43
+
44
+ INTERNAL_ONLY_KEYS = %i[
45
+ _join
46
+ _selection
47
+ _preset_mode
48
+ _preset_pruned_keys
49
+ _preset_conflicts
50
+ _curation_conflict_type
51
+ _curation_conflict_count
52
+ _runtime_flags
53
+ _hits
54
+ _curation
55
+ ].freeze
56
+
57
+ # Build a single-search request for a collection.
58
+ #
59
+ # @param collection [String] logical collection name (alias)
60
+ # @param compiled_params [SearchEngine::CompiledParams] sanitized, deterministic params
61
+ # @param url_opts [Hash] URL/common params such as cache controls
62
+ # @return [Request] normalized request ready for transport
63
+ # @see `https://typesense.org/docs/latest/api/documents.html#search-document`
64
+ def self.build_search(collection:, compiled_params:, url_opts: {})
65
+ name = collection.to_s
66
+ body_hash = sanitize_body_params(compiled_params.to_h)
67
+ # Ensure deterministic ordering even after sanitization
68
+ body_json = SearchEngine::CompiledParams.from(body_hash).to_json
69
+
70
+ Request.new(
71
+ http_method: :post,
72
+ path: COLLECTIONS_PREFIX + name + DOCUMENTS_SEARCH_SUFFIX,
73
+ params: url_opts || {},
74
+ headers: DEFAULT_HEADERS_JSON,
75
+ body: body_hash,
76
+ body_json: body_json
77
+ )
78
+ end
79
+
80
+ # Remove internal-only keys from the HTTP payload (copied from previous client behavior).
81
+ #
82
+ # @param params [Hash] possibly containing internal keys
83
+ # @return [Hash] new Hash without internal-only keys
84
+ def self.sanitize_body_params(params)
85
+ payload = params.dup
86
+ INTERNAL_ONLY_KEYS.each { |k| payload.delete(k) }
87
+ payload
88
+ end
89
+ private_class_method :sanitize_body_params
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SearchEngine
4
+ class Client
5
+ module Services
6
+ # Shared helpers for service objects that delegate to {SearchEngine::Client}.
7
+ #
8
+ # Provides convenient access to the parent client and its private helpers
9
+ # without widening the public API surface of the client itself.
10
+ class Base
11
+ # @param client [SearchEngine::Client]
12
+ def initialize(client)
13
+ @client = client
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :client
19
+
20
+ def config
21
+ client.__send__(:config)
22
+ end
23
+
24
+ def typesense
25
+ client.__send__(:typesense)
26
+ end
27
+
28
+ def typesense_for_import
29
+ client.__send__(:typesense_for_import)
30
+ end
31
+
32
+ def build_typesense_client_with_read_timeout(seconds)
33
+ client.__send__(:build_typesense_client_with_read_timeout, seconds)
34
+ end
35
+
36
+ def build_typesense_client
37
+ client.__send__(:build_typesense_client)
38
+ end
39
+
40
+ def with_exception_mapping(*args, &block)
41
+ client.__send__(:with_exception_mapping, *args, &block)
42
+ end
43
+
44
+ def instrument(*args)
45
+ client.__send__(:instrument, *args)
46
+ end
47
+
48
+ def log_success(*args)
49
+ client.__send__(:log_success, *args)
50
+ end
51
+
52
+ def current_monotonic_ms
53
+ client.__send__(:current_monotonic_ms)
54
+ end
55
+
56
+ def derive_cache_opts(url_opts)
57
+ client.__send__(:derive_cache_opts, url_opts)
58
+ end
59
+
60
+ def symbolize_keys_deep(payload)
61
+ client.__send__(:symbolize_keys_deep, payload)
62
+ end
63
+
64
+ def validate_single!(*args)
65
+ client.__send__(:validate_single!, *args)
66
+ end
67
+
68
+ def validate_multi!(*args)
69
+ client.__send__(:validate_multi!, *args)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end