search-engine-for-typesense 30.9.2 → 30.9.3

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +47 -13
  4. data/lib/search_engine/active_record_syncable.rb +85 -197
  5. data/lib/search_engine/ast/node.rb +3 -6
  6. data/lib/search_engine/async_partition_coordinator.rb +5 -3
  7. data/lib/search_engine/client/services/search.rb +2 -0
  8. data/lib/search_engine/client.rb +9 -3
  9. data/lib/search_engine/compiler.rb +33 -128
  10. data/lib/search_engine/deletion.rb +12 -9
  11. data/lib/search_engine/dsl/parser.rb +51 -71
  12. data/lib/search_engine/engine.rb +25 -16
  13. data/lib/search_engine/filters/sanitizer.rb +162 -126
  14. data/lib/search_engine/hydration/materializers.rb +53 -118
  15. data/{app/search_engine → lib}/search_engine/index_partition_job.rb +1 -1
  16. data/lib/search_engine/indexing_run_store/rails_cache.rb +44 -33
  17. data/lib/search_engine/indexing_run_store.rb +10 -0
  18. data/lib/search_engine/joins/guard.rb +14 -8
  19. data/lib/search_engine/multi.rb +92 -6
  20. data/lib/search_engine/multi_result.rb +40 -23
  21. data/lib/search_engine/notifications/compact_logger.rb +2 -2
  22. data/lib/search_engine/observability.rb +6 -6
  23. data/lib/search_engine/postgres_outbox/drainer.rb +7 -12
  24. data/lib/search_engine/postgres_outbox/event.rb +12 -0
  25. data/lib/search_engine/postgres_outbox/repository.rb +48 -102
  26. data/lib/search_engine/postgres_outbox.rb +1 -0
  27. data/lib/search_engine/registry.rb +27 -28
  28. data/lib/search_engine/relation/compiler.rb +18 -33
  29. data/lib/search_engine/relation/dsl/filters.rb +71 -175
  30. data/lib/search_engine/relation/dx.rb +3 -1
  31. data/lib/search_engine/relation/materializers.rb +6 -3
  32. data/lib/search_engine/relation/options.rb +5 -26
  33. data/lib/search_engine/relation/state.rb +3 -2
  34. data/lib/search_engine/relation.rb +5 -4
  35. data/lib/search_engine/result.rb +33 -2
  36. data/lib/search_engine/schema.rb +23 -24
  37. data/lib/search_engine/test/offline_client.rb +2 -2
  38. data/lib/search_engine/test/stub_client.rb +9 -16
  39. data/lib/search_engine/update.rb +1 -1
  40. data/lib/search_engine/version.rb +1 -1
  41. data/lib/search_engine.rb +11 -149
  42. data/lib/tasks/search_engine.rake +3 -5
  43. metadata +17 -3
  44. /data/{app/search_engine → lib}/search_engine/postgres_outbox/drain_job.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2a45aebc5484036b192915f9de295af3efbce20c7831c3e301dd9608b68a26d
4
- data.tar.gz: e7274a561a6f852f5daa8489f33349bbdf430da811f8e0db3556c36547a1098e
3
+ metadata.gz: 8839a1d87b60901a030030a293152432bbf593b215ba7fa7292eac10e724a2bb
4
+ data.tar.gz: 84b2e507deee029c7ef87165a5f5ca2f6a37450fbf25b59bb6208a42c0d4c25b
5
5
  SHA512:
6
- metadata.gz: 38f920eb23837108385ad80e09607d6e8f221847660af0870de49552110305d5b1a8d8a4186d0f9635b609ccafdaf0817127fa911cf5f3a9f9d17f1d6ea3d032
7
- data.tar.gz: c89b82cbb8575cc4608bbeab0536599aa0b5d37f26198117e6f519df40fd0b588919297c7fa26b0466efaa370fce9df56cd196e9a21cfcb15a7cebd679bd01a8
6
+ metadata.gz: e0bb71bd0d9ff89f7053286c6b683433e0e49ab9e5d2cd514e2bc778f33eff821236f840b14358922afb4cc091802800616290d94a807845a87528f7f62959ff
7
+ data.tar.gz: 79199d5c13cdbbc4acb8b2a5c2570e6934bd675447e5ea8d4da517f68047d3fc0060e16af69f929686b8c8ebe84e298f498e8a0b720c25bb57d1e41098700ab6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 30.9.3
4
+
5
+ - Preserve tenant filters and scoped clients across searches and bulk writes. Reject ambiguous literals,
6
+ use the joined collection’s field types, and keep bound values out of parser errors.
7
+ - Honor exact offsets, fetch one hit for `find_by`, and hydrate selected hits once. Typed multi-search
8
+ reports individual errors while raw multi-search preserves the server response.
9
+ - Stop schema cutover on incomplete imports and retain both collections after an uncertain alias write.
10
+ Conditional rollback requires an exclusive guard shared by all alias writers.
11
+ - Fence PostgreSQL outbox acknowledgements by the active claim. Drain and restart older workers during rollout.
12
+ Partition workers update their own entry without reading the full run.
13
+ - Preserve mapped document IDs across STI, Rails autoloading and reload. Inline hosts can load Active Job later.
14
+
3
15
  ## 30.9.2
4
16
 
5
17
  - Check recent event IDs during catch-up so a returning delivery target can discover new work
data/README.md CHANGED
@@ -52,7 +52,13 @@ See [Quickstart](https://nikita-shkoda.mintlify.app/projects/search-engine-for-t
52
52
 
53
53
  By default, the gem manages a dedicated Zeitwerk loader for your SearchEngine models under `app/search_engine/`. The loader is initialized after Rails so that application models/constants are available, auto-reloads in development, and is eager-loaded in production/test.
54
54
 
55
- Customize or disable via configuration:
55
+ Mappers under the host’s `app/models/search_engine` are registered by the Rails loader at boot and reload,
56
+ including nested classes whose names differ from their declared collection. For a mapper managed outside
57
+ these model directories, reference it in the callback declaration, for example
58
+ `search_engine_syncable collection: Catalog::ItemIndex.collection`. This loads its identity strategy before
59
+ the first callback. A bare collection uses the Active Record primary key for deletes.
60
+
61
+ Configure the dedicated loader with:
56
62
 
57
63
  ```ruby
58
64
  # config/initializers/search_engine.rb
@@ -66,9 +72,22 @@ SQL sources stream PostgreSQL queries in their own read-only transaction. Start
66
72
  outside an existing transaction; an active transaction is rejected before cursor SQL runs.
67
73
  The configured statement timeout applies only to the streaming transaction.
68
74
 
75
+ Use hash predicates or bound placeholders for application values. A raw filter string is a Typesense
76
+ expression and must come from trusted code. Values that Typesense cannot represent as exact literals,
77
+ including a wildcard document ID or a string containing a backtick, raise before a search or bulk write.
78
+ Numeric comparisons in `where.not` invert the operator.
79
+ A relation with `limit(2).offset(1)` starts at the second matching document. An explicit `page` or
80
+ `per` takes precedence over the offset. Use a stable sort when reading successive windows.
81
+
69
82
  Joined filters execute as native Typesense joins and require valid collection references.
70
- Search configuration and missing-reference errors propagate to callers; they do not
71
- produce empty results or trigger client-side joins.
83
+ The target model's field type determines how Ruby dates and times are encoded. For a target
84
+ without a declared field type, pass the stored value as an ISO string or integer epoch.
85
+ Search configuration and missing-reference errors propagate to the caller.
86
+
87
+ Single search, federated search and multi-page `all!` use the configured client. A failing entry in
88
+ `multi_search` or `multi_search_result` raises with the entry label and provider status.
89
+ Use `multi_search_raw` to handle successful and failed entries separately. Setting `.cache(false)`
90
+ on an entry disables caching for the whole federated request.
72
91
 
73
92
  ## Usage examples
74
93
 
@@ -230,8 +249,8 @@ bundle exec ruby examples/demo_shop/bin/query_smoke
230
249
  This compiles geo/vector requests and checks metadata fixtures without HTTP requests or model downloads.
231
250
  See [the demo instructions](examples/demo_shop/README.md#query-examples) for explicitly isolated live execution,
232
251
  including highlighting through `Client#search` and `hit.snippet_for`.
233
- CI uses a disposable `typesense/typesense:30.2` service for geo, vector, cache and live CLI checks,
234
- including the built-in embedding model. The default unit suite runs separately without integration ENV.
252
+ CI runs query, cache, schema and demo checks against Typesense 30.2, including the built-in embedding
253
+ model. A separate PostgreSQL job exercises outbox claims and concurrency.
235
254
 
236
255
  `upsert_bulk` defaults to `on_failure: :raise`. Use the exact symbol `:return` when the caller needs to
237
256
  handle valid Typesense row failures itself. Malformed responses and response/document count mismatches always
@@ -301,7 +320,8 @@ only then lets the schema lifecycle swap the alias. If any partition fails or ti
301
320
  alias target remains active.
302
321
 
303
322
  Use a shared `Rails.cache` backend, or provide `c.indexer.partition_run_store`, so worker processes and
304
- the parent indexing process can see the same run metadata. Size the queue carefully: worker concurrency
323
+ the parent indexing process can see the same run metadata. Worker transitions update one partition.
324
+ Explicit snapshots read the complete run. Size the queue carefully: worker concurrency
305
325
  multiplies with any per-partition `max_parallel` setting.
306
326
 
307
327
  A custom partition run store must implement:
@@ -332,8 +352,10 @@ available.
332
352
  collection creation, indexing, alias swap, and retention. `SearchEngine::Schema.rollback` uses the same guard
333
353
  around target resolution, validation, and its alias swap. In-place schema updates intentionally do not invoke
334
354
  the guard. The guard must yield exactly once; not yielding or yielding repeatedly raises `ArgumentError`.
335
- Guard errors and indexing errors propagate, and an interrupted pre-swap physical collection is still cleaned
336
- up.
355
+ The schema apply task requires every partition import to succeed before it publishes the new physical
356
+ collection. On failure before the alias write, cleanup removes the candidate once it verifies the target. If the alias write raises,
357
+ the server may already have accepted it. Both physical collections are retained. Check the live alias
358
+ and the candidate before pruning or retrying.
337
359
 
338
360
  In delivery-target outbox deployments, use the guard to stop new target claims, wait for already-claimed
339
361
  deliveries to acknowledge, and prevent cooperating direct writers from crossing the alias swap:
@@ -384,8 +406,10 @@ points to after a later cutover.
384
406
  Rollback is conservative and retry-safe. The backward-compatible form rolls back one generation only when the
385
407
  alias points at the newest retained physical; once it no longer does, repeating the call returns
386
408
  `action: :already_rolled_back` without another swap. When newer orphaned physicals exist, or when an operator
387
- needs an auditable destination, pass the retained target explicitly. Add `expected_current` for compare-and-swap
388
- protection:
409
+ needs an auditable destination, pass the retained target explicitly. To require a specific source, pass
410
+ `expected_current` and configure `schema.around_rebuild` to acquire an exclusive, cross-process lock.
411
+ Every writer of that logical alias must share the lock. Typesense exposes separate read and write operations,
412
+ so direct administrative writes bypass the precondition.
389
413
 
390
414
  ```ruby
391
415
  SearchEngine::Schema.rollback(
@@ -402,9 +426,11 @@ rails 'search_engine:schema:rollback[products]'
402
426
  rails 'search_engine:schema:rollback[products,products_20250101_000000_001,products_20250102_000000_001]'
403
427
  ```
404
428
 
405
- An explicit destination must be a retained physical older than the current/expected source. Retrying after the
406
- alias already reached that destination is a successful no-op. Any other expected-current mismatch raises
407
- `SearchEngine::Schema::RollbackConflict` without changing the alias.
429
+ An explicit destination must be a retained physical older than the current or expected source. A retry
430
+ succeeds without another swap when the alias already points at the destination and the named generations
431
+ are still retained. Any other expected-current mismatch raises
432
+ `SearchEngine::Schema::RollbackConflict`. After an ambiguous alias response, reconcile the remote state
433
+ before allowing another writer to proceed.
408
434
 
409
435
  This protocol only gates delivery-target claims. Legacy single-target event claims have no target identity and
410
436
  must be stopped before a rebuild. Likewise, an unguarded direct writer can still cross the swap. In
@@ -643,6 +669,14 @@ Drain slots are scheduler bookkeeping and are deliberately not changed. Configur
643
669
  before each database mutation, but Ruby configuration and PostgreSQL cannot share an atomic lock, so do not
644
670
  change delivery-target configuration concurrently with retirement.
645
671
 
672
+ Single-target repository callers must pass the events returned by `claim_pending` to acknowledgement and
673
+ failure methods. The database matches each event against its current processing claim. Expired workers
674
+ can finish an external request, but only the current owner can change the event state. Drain and restart
675
+ workers running earlier versions when enabling this protection.
676
+
677
+ Active Record subclasses inherit the parent’s sync configuration and may override it. Callbacks resolve
678
+ the current mapper at execution time, including the document identity used for destroy.
679
+
646
680
  Pair triggered source models with `sync_strategy: :postgres_outbox` so Active Record callbacks do not also
647
681
  write to Typesense for the same changes:
648
682
 
@@ -1,14 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/concern'
4
+ require 'active_support/core_ext/class/attribute'
5
+ require 'active_support/inflector'
4
6
 
5
7
  module SearchEngine
6
8
  # ActiveRecord concern to keep a Typesense collection in sync.
7
9
  #
8
10
  # Include into an AR model and call {.search_engine_syncable} to store sync
9
- # metadata. The default callback strategy installs lifecycle callbacks that
10
- # upsert on create/update and delete on destroy; the postgres_outbox strategy
11
- # stores metadata without installing callbacks.
11
+ # metadata. The default callback strategy upserts on create/update and deletes
12
+ # on destroy. The postgres_outbox strategy stores metadata without installing
13
+ # callbacks. Subclasses inherit the configuration unless they declare their own.
12
14
  #
13
15
  # @example
14
16
  # class Product < ApplicationRecord
@@ -18,86 +20,61 @@ module SearchEngine
18
20
  module ActiveRecordSyncable
19
21
  extend ActiveSupport::Concern
20
22
 
23
+ # Rails deduplicates after_commit filters by method name, even for separate actions.
24
+ CALLBACKS = {
25
+ create: :__se_syncable_create!,
26
+ update: :__se_syncable_upsert!,
27
+ destroy: :__se_syncable_delete!
28
+ }.freeze
29
+ private_constant :CALLBACKS
30
+
31
+ included do
32
+ class_attribute :_se_syncable_config, instance_writer: false, instance_predicate: false
33
+ end
34
+
21
35
  class_methods do
22
- # Configure SearchEngine synchronization for this ActiveRecord model.
23
- #
24
- # - collection: defaults to the AR class tableized name (snake_case, plural)
25
- # - on: one or many of :create, :update, :destroy (strings or symbols)
26
- # - sync_strategy: :callbacks (default) or :postgres_outbox
36
+ # Configure SearchEngine synchronization for this model and its subclasses.
37
+ # SearchEngine models can register after this declaration.
27
38
  #
28
- # Validates that either a physical Typesense collection exists for the
29
- # given name or a SearchEngine model is registered for it. Mapping for
30
- # create/update requires a SearchEngine model; when missing, an error is
31
- # raised with guidance.
32
- #
33
- # @param collection [Symbol, String, nil]
34
- # @param on [Array<Symbol,String>, Symbol, String, nil]
35
- # @param sync_strategy [Symbol, String, nil]
36
- # @return [Class] self (for macro chaining)
39
+ # @param collection [Symbol, String, nil] defaults to the tableized model name
40
+ # @param on [Array<Symbol,String>, Symbol, String, nil] one or more of create, update and destroy
41
+ # @param sync_strategy [Symbol, String, nil] callbacks (default) or postgres_outbox
42
+ # @return [Class] self
37
43
  def search_engine_syncable(collection: nil, on: nil, sync_strategy: :callbacks)
38
- effective_actions = on
39
-
40
- cfg = SearchEngine::ActiveRecordSyncable.__normalize_config_for(
41
- self,
42
- collection: collection,
43
- actions: effective_actions,
44
- sync_strategy: sync_strategy
45
- )
46
-
47
- # Store config on the AR class (used by instance methods)
48
- instance_variable_set(:@__se_syncable_cfg__, cfg)
49
-
50
- SearchEngine::ActiveRecordSyncable.__register_callbacks_for(self, cfg) if cfg[:sync_strategy] == :callbacks
51
-
44
+ previous = _se_syncable_config
45
+ config = {
46
+ logical: (collection || ActiveSupport::Inflector.tableize(name)).to_s.dup.freeze,
47
+ actions: SearchEngine::ActiveRecordSyncable.__normalize_actions(on).freeze,
48
+ sync_strategy: SearchEngine::ActiveRecordSyncable.__normalize_sync_strategy(sync_strategy),
49
+ timing: SearchEngine.config.syncable_callback_timing
50
+ }.freeze
51
+ self._se_syncable_config = config
52
+ if config[:sync_strategy] == :callbacks && config != previous
53
+ SearchEngine::ActiveRecordSyncable.__register_callbacks_for(self, config)
54
+ end
52
55
  self
53
56
  end
54
57
  end
55
58
 
56
- # Upsert this record into the configured Typesense collection using the
57
- # mapping defined on the corresponding SearchEngine model.
59
+ # @api private
60
+ def __se_syncable_create!
61
+ __se_syncable_upsert!
62
+ end
63
+
64
+ # Upsert this record using the configured SearchEngine model.
58
65
  # @return [void]
59
66
  def __se_syncable_upsert!
60
- cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
61
- se_klass = cfg[:se_klass]
62
- unless se_klass
63
- # Lazy-resolve the SearchEngine model to avoid boot-time ordering issues
64
- begin
65
- se_klass = SearchEngine.collection_for(cfg[:logical])
66
- cfg[:se_klass] = se_klass if se_klass
67
- rescue StandardError
68
- se_klass = nil
69
- end
70
- end
71
- unless se_klass
72
- SearchEngine.config.logger&.warn(
73
- "search_engine_syncable: no SearchEngine model registered for '#{cfg[:logical]}'; skip upsert"
74
- )
75
- return
76
- end
77
-
78
- se_klass.upsert(record: self)
79
- rescue StandardError => error
80
- SearchEngine.config.logger&.error("search_engine_syncable upsert failed: #{error}")
67
+ sync_search_engine_record
81
68
  end
82
69
 
83
- # Delete this record's document from the configured Typesense collection.
84
- # Uses the SearchEngine model's identity strategy when available.
70
+ # Delete the mapped document. Collections without a model use the ActiveRecord primary key.
85
71
  # @return [void]
86
72
  def __se_syncable_delete!
87
- cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
88
- logical = cfg[:logical]
89
- se_klass = cfg[:se_klass]
90
-
91
- document_id = if se_klass
92
- se_klass.compute_document_id(self)
93
- else
94
- respond_to?(:id) ? id.to_s : nil
95
- end
96
-
97
- if document_id.nil? || document_id.strip.empty?
98
- SearchEngine.config.logger&.warn(
99
- "search_engine_syncable: cannot delete without id for '#{logical}'"
100
- )
73
+ logical = __se_syncable_logical
74
+ model = __se_syncable_model(logical)
75
+ document_id = model ? model.compute_document_id(self) : id.to_s
76
+ if document_id.blank?
77
+ SearchEngine.config.logger&.warn("search_engine_syncable: cannot delete without id for '#{logical}'")
101
78
  return
102
79
  end
103
80
 
@@ -108,123 +85,51 @@ module SearchEngine
108
85
  SearchEngine.config.logger&.error("search_engine_syncable delete failed: #{error}")
109
86
  end
110
87
 
111
- # Return the associated SearchEngine record for this ActiveRecord instance.
112
- #
113
- # Resolves the SearchEngine model lazily when necessary and computes the
114
- # document id via the model's `identify_by` strategy. Returns nil when the
115
- # model mapping is unavailable or the id cannot be determined.
116
- #
117
- # @return [Object, nil] hydrated SearchEngine model instance or nil when not found
88
+ # Return the associated SearchEngine record using the current model and identity strategy.
89
+ # @return [Object, nil] hydrated SearchEngine model instance or nil when unavailable
118
90
  def search_engine_record
119
- cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
120
- se_klass = cfg[:se_klass]
121
- if se_klass.nil?
122
- begin
123
- logical = cfg[:logical] || self.class.name.to_s
124
- se_klass = SearchEngine.collection_for(logical)
125
- cfg[:se_klass] = se_klass if se_klass
126
- rescue StandardError
127
- return nil
128
- end
129
- end
91
+ model = __se_syncable_model
92
+ return unless model
130
93
 
131
- doc_id = begin
132
- se_klass.compute_document_id(self)
133
- rescue StandardError => error
134
- # When a custom identify_by is configured on the SearchEngine model,
135
- # do not fall back to the ActiveRecord id on computation errors as it
136
- # may point to a different document. Only fall back to AR id when
137
- # identify_by is not defined at all.
138
- if se_klass.instance_variable_defined?(:@identify_by_proc)
139
- SearchEngine.config.logger&.warn(
140
- "search_engine_syncable: identify_by failed to compute id for '#{cfg[:logical]}' (#{error.class})"
141
- )
142
- nil
143
- else
144
- respond_to?(:id) ? id.to_s : nil
145
- end
146
- end
147
- return nil if doc_id.nil? || doc_id.to_s.strip.empty?
148
-
149
- se_klass.find(doc_id)
150
- rescue StandardError
94
+ document_id = model.compute_document_id(self)
95
+ model.find(document_id) if document_id.present?
96
+ rescue StandardError => error
97
+ SearchEngine.config.logger&.error("search_engine_syncable lookup failed: #{error}")
151
98
  nil
152
99
  end
153
100
 
154
101
  # Map this ActiveRecord instance using the associated SearchEngine model and
155
102
  # upsert it into the collection.
156
- #
157
103
  # @return [Integer] number of upserted documents (0 or 1)
158
104
  def sync_search_engine_record
159
- cfg = self.class.instance_variable_get(:@__se_syncable_cfg__) || {}
160
- se_klass = cfg[:se_klass]
161
- if se_klass.nil?
162
- begin
163
- logical = cfg[:logical] || self.class.name.to_s
164
- se_klass = SearchEngine.collection_for(logical)
165
- cfg[:se_klass] = se_klass if se_klass
166
- rescue StandardError => error
167
- SearchEngine.config.logger&.warn(
168
- "search_engine_syncable: cannot resolve model for sync (#{error.class})"
169
- )
170
- return 0
171
- end
172
- end
105
+ model = __se_syncable_model
106
+ return model.upsert(record: self) if model
173
107
 
174
- se_klass.upsert(record: self)
108
+ SearchEngine.config.logger&.warn(
109
+ "search_engine_syncable: no SearchEngine model registered for '#{__se_syncable_logical}'; skip upsert"
110
+ )
111
+ 0
175
112
  rescue StandardError => error
176
113
  SearchEngine.config.logger&.error("search_engine_syncable sync failed: #{error}")
177
114
  0
178
115
  end
179
116
 
180
- module_function
181
-
182
- # @api private
183
- # @param ar_klass [Class]
184
- # @param collection [String,Symbol,nil]
185
- # @param actions [Array<String,Symbol>, String, Symbol, nil]
186
- # @param sync_strategy [String, Symbol, nil]
187
- # @return [Hash]
188
- def __normalize_config_for(ar_klass, collection:, actions:, sync_strategy: :callbacks)
189
- require 'active_support/inflector'
190
-
191
- logical = (collection || ActiveSupport::Inflector.tableize(ar_klass.name)).to_s
192
- normalized_actions = __normalize_actions(actions)
193
- normalized_sync_strategy = __normalize_sync_strategy(sync_strategy)
117
+ private
194
118
 
195
- # Best-effort resolve SearchEngine model now; fall back to lazy resolution
196
- se_klass = begin
197
- SearchEngine.collection_for(logical)
198
- rescue StandardError
199
- nil
200
- end
201
-
202
- if se_klass.nil? && (normalized_actions.include?(:create) || normalized_actions.include?(:update))
203
- SearchEngine.config.logger&.warn(
204
- "search_engine_syncable: mapping for '#{logical}' not found at boot; will resolve lazily at runtime"
205
- )
206
- end
119
+ def __se_syncable_model(logical = __se_syncable_logical)
120
+ SearchEngine.collection_for(logical) { nil }
121
+ end
207
122
 
208
- {
209
- logical: logical,
210
- actions: normalized_actions,
211
- sync_strategy: normalized_sync_strategy,
212
- se_klass: se_klass
213
- }
123
+ def __se_syncable_logical
124
+ _se_syncable_config ? _se_syncable_config.fetch(:logical) : self.class.name.to_s
214
125
  end
215
126
 
127
+ module_function
128
+
216
129
  # @api private
217
- # @param actions [Array<String,Symbol>, String, Symbol, nil]
218
- # @return [Array<Symbol>]
219
130
  def __normalize_actions(actions)
220
- allowed = %i[create update destroy]
221
- list =
222
- if actions.nil?
223
- allowed
224
- else
225
- Array(actions).map { |a| a.to_s.downcase.strip.to_sym }
226
- end
227
-
131
+ allowed = CALLBACKS.keys
132
+ list = actions.nil? ? allowed : Array(actions).map { |action| action.to_s.downcase.strip.to_sym }
228
133
  invalid = list - allowed
229
134
  raise ArgumentError, "search_engine_syncable: actions must be within #{allowed.inspect}" unless invalid.empty?
230
135
 
@@ -232,8 +137,6 @@ module SearchEngine
232
137
  end
233
138
 
234
139
  # @api private
235
- # @param strategy [String, Symbol, nil]
236
- # @return [Symbol]
237
140
  def __normalize_sync_strategy(strategy)
238
141
  allowed = %i[callbacks postgres_outbox]
239
142
  normalized = strategy.nil? ? :callbacks : strategy.to_s.downcase.strip.to_sym
@@ -245,37 +148,22 @@ module SearchEngine
245
148
  normalized
246
149
  end
247
150
 
248
- # (no-op placeholder kept for backwards compatibility of method table in case of reloads)
249
-
250
151
  # @api private
251
- # @param ar_klass [Class]
252
- # @param cfg [Hash]
253
- # @return [void]
254
- def __register_callbacks_for(ar_klass, cfg)
255
- if ar_klass.instance_variable_defined?(:@__se_syncable_callbacks_installed__) &&
256
- ar_klass.instance_variable_get(:@__se_syncable_callbacks_installed__)
257
- return
258
- end
259
-
260
- actions = cfg[:actions]
261
- timing = begin
262
- SearchEngine.config.syncable_callback_timing
263
- rescue StandardError
264
- :after_commit
265
- end
266
-
267
- if timing == :after_commit
268
- upsert_actions = actions & %i[create update]
269
- ar_klass.after_commit :__se_syncable_upsert!, on: upsert_actions unless upsert_actions.empty?
270
- ar_klass.after_destroy_commit :__se_syncable_delete! if actions.include?(:destroy)
271
- else
272
- ar_klass.after_create :__se_syncable_upsert! if actions.include?(:create)
273
- ar_klass.after_update :__se_syncable_upsert! if actions.include?(:update)
274
- ar_klass.after_destroy :__se_syncable_delete! if actions.include?(:destroy)
152
+ def __register_callbacks_for(ar_klass, config)
153
+ timing = config.fetch(:timing)
154
+ config.fetch(:actions).each do |action|
155
+ # An inherited callback can be narrowed or disabled by a subclass declaration.
156
+ enabled = lambda do
157
+ current = _se_syncable_config
158
+ current[:sync_strategy] == :callbacks && current[:timing] == timing && current[:actions].include?(action)
159
+ end
160
+ method = CALLBACKS.fetch(action)
161
+ if timing == :after_commit
162
+ ar_klass.after_commit method, on: action, if: enabled
163
+ else
164
+ ar_klass.public_send("after_#{action}", method, if: enabled)
165
+ end
275
166
  end
276
-
277
- ar_klass.instance_variable_set(:@__se_syncable_callbacks_installed__, true)
278
- nil
279
167
  end
280
168
  end
281
169
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'search_engine/filters/sanitizer'
4
+
3
5
  module SearchEngine
4
6
  module AST
5
7
  # Abstract base node for the predicate AST.
@@ -154,12 +156,7 @@ module SearchEngine
154
156
  # @param field [String, Symbol]
155
157
  # @return [String]
156
158
  def validate_field!(field)
157
- raise ArgumentError, 'field must be a String or Symbol' unless field.is_a?(String) || field.is_a?(Symbol)
158
-
159
- str = field.to_s.strip
160
- raise ArgumentError, 'field cannot be blank' if str.empty?
161
-
162
- str
159
+ SearchEngine::Filters::Sanitizer.field_name(field, joined: true).dup.freeze
163
160
  end
164
161
 
165
162
  # Ensure values is a non-empty Array
@@ -112,8 +112,9 @@ module SearchEngine
112
112
 
113
113
  def timeout_result(store, run_id, snapshot)
114
114
  mark_non_terminal_failed(store, run_id, snapshot)
115
- snapshot = store.snapshot(run_id: run_id) || snapshot
116
- current_result = SearchEngine::IndexingRun.aggregate_result(snapshot)
115
+ snapshot = store.snapshot(run_id: run_id)
116
+ current_result = result_for_snapshot(snapshot)
117
+ return finish_failure(snapshot, current_result) if invalid_snapshot_result?(current_result)
117
118
  return finish_success(snapshot, current_result) if current_result[:status] == :ok
118
119
 
119
120
  result = failed_result(
@@ -130,11 +131,12 @@ module SearchEngine
130
131
  partitions = snapshot && snapshot[:partitions]
131
132
  return unless partitions.is_a?(Hash)
132
133
 
134
+ writer = SearchEngine::IndexingRunStore.partition_writer(store)
133
135
  partitions.each do |partition_key, entry|
134
136
  status = entry[:status].to_s
135
137
  next if %w[succeeded failed].include?(status)
136
138
 
137
- store.mark_failed(
139
+ writer.mark_failed(
138
140
  run_id: run_id,
139
141
  partition_key: partition_key,
140
142
  error: 'partition did not finish before timeout'
@@ -24,6 +24,8 @@ module SearchEngine
24
24
  instrument(:get, path, duration, cache_params, request_token: start)
25
25
  log_success(:get, path, start, cache_params)
26
26
 
27
+ return yield(result) if block_given?
28
+
27
29
  klass = begin
28
30
  SearchEngine.collection_for(collection)
29
31
  rescue ArgumentError
@@ -29,12 +29,13 @@ module SearchEngine
29
29
  # @param collection [String] collection name
30
30
  # @param params [Hash] Typesense search parameters (q, query_by, etc.)
31
31
  # @param url_opts [Hash] URL/common knobs (use_cache, cache_ttl)
32
+ # @yieldparam raw [Hash] optional result builder receives the response before hydration
32
33
  # @return [SearchEngine::Result] Wrapped response with hydrated hits
33
34
  # @raise [SearchEngine::Errors::InvalidParams, SearchEngine::Errors::*]
34
35
  # @see `https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/client`
35
36
  # @see `https://typesense.org/docs/latest/api/documents.html#search-document`
36
- def search(collection:, params:, url_opts: {})
37
- services.fetch(:search).call(collection: collection, params: params, url_opts: url_opts)
37
+ def search(collection:, params:, url_opts: {}, &result_builder)
38
+ services.fetch(:search).call(collection: collection, params: params, url_opts: url_opts, &result_builder)
38
39
  end
39
40
 
40
41
  # Resolve a logical collection name that might be an alias to the physical collection name.
@@ -387,6 +388,11 @@ module SearchEngine
387
388
  services.fetch(:search).multi(searches: searches, url_opts: url_opts)
388
389
  end
389
390
 
391
+ # Default URL-level search options for this client's configuration.
392
+ def search_url_options
393
+ ClientOptions.url_options_from_config(config)
394
+ end
395
+
390
396
  # Clear the Typesense server-side search cache.
391
397
  #
392
398
  # @return [Hash] response payload from Typesense (symbolized keys)
@@ -692,7 +698,7 @@ module SearchEngine
692
698
  end
693
699
 
694
700
  def derive_cache_opts(url_opts)
695
- merged = ClientOptions.url_options_from_config(config)
701
+ merged = search_url_options
696
702
  merged[:use_cache] = url_opts[:use_cache] if url_opts.key?(:use_cache) && !url_opts[:use_cache].nil?
697
703
  merged[:cache_ttl] = Integer(url_opts[:cache_ttl]) if url_opts.key?(:cache_ttl)
698
704
  merged