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
@@ -17,25 +17,7 @@ module SearchEngine
17
17
  # @param args [Array<Object>]
18
18
  # @return [SearchEngine::Relation]
19
19
  def not(*args)
20
- @relation.send(:validate_not_args!, args)
21
- nodes = Array(@relation.send(:build_ast_with_empty_array_rewrites, args, negated: true))
22
-
23
- # Invert non-hidden predicates (Eq, In) returned by the builder
24
- negated = nodes.map do |node|
25
- case node
26
- when SearchEngine::AST::Eq
27
- SearchEngine::AST.not_eq(node.field, node.value)
28
- when SearchEngine::AST::In
29
- SearchEngine::AST.not_in(node.field, node.values)
30
- else
31
- node
32
- end
33
- end
34
-
35
- @relation.send(:spawn) do |s|
36
- s[:ast] = Array(s[:ast]) + negated
37
- s[:filters] = Array(s[:filters])
38
- end
20
+ @relation.not(*args)
39
21
  end
40
22
  end
41
23
 
@@ -47,11 +29,9 @@ module SearchEngine
47
29
  return self if args.nil? || args.empty?
48
30
  return self if blank_where_args?(args)
49
31
 
50
- ast_nodes = build_ast_with_empty_array_rewrites(args, negated: false)
51
- fragments = normalize_where(args)
32
+ ast_nodes = build_filter_nodes(args, negated: false)
52
33
  spawn do |s|
53
34
  s[:ast] = Array(s[:ast]) + Array(ast_nodes)
54
- s[:filters] = Array(s[:filters]) + fragments
55
35
  end
56
36
  end
57
37
 
@@ -61,23 +41,10 @@ module SearchEngine
61
41
  # @return [SearchEngine::Relation]
62
42
  def not(*args)
63
43
  validate_not_args!(args)
64
- nodes = Array(build_ast_with_empty_array_rewrites(args, negated: true))
65
-
66
- negated = nodes.map do |node|
67
- case node
68
- when SearchEngine::AST::Eq
69
- SearchEngine::AST.not_eq(node.field, node.value)
70
- when SearchEngine::AST::In
71
- SearchEngine::AST.not_in(node.field, node.values)
72
- else
73
- node
74
- end
75
- end
44
+ nodes = build_filter_nodes(args, negated: true)
45
+ raise SearchEngine::Errors::InvalidOperator, 'where.not requires a predicate' if nodes.empty?
76
46
 
77
- spawn do |s|
78
- s[:ast] = Array(s[:ast]) + negated
79
- s[:filters] = Array(s[:filters])
80
- end
47
+ spawn { |state| state[:ast] = Array(state[:ast]) + nodes }
81
48
  end
82
49
 
83
50
  # Replace all predicates with a new where input.
@@ -249,93 +216,77 @@ module SearchEngine
249
216
  end
250
217
  end
251
218
 
252
- # Build AST nodes, rewriting:
253
- # - empty-array predicates to hidden *_empty flags when enabled (existing behavior)
254
- # - nil predicates to hidden *_blank flags when `optional` is enabled (new behavior)
255
- # Delegates other inputs to the DSL parser.
256
- def build_ast_with_empty_array_rewrites(args, negated: false)
257
- # Preserve input shape so template bind arrays stay intact:
258
- # where('field IN ?', [1, 2]) => ['field IN ?', [1, 2]]
259
- items = Array(args).compact
260
- return [] if items.empty?
261
-
262
- out_nodes = []
263
- non_hash_items = []
264
-
265
- items.each do |entry|
219
+ def build_filter_nodes(args, negated:)
220
+ nodes = []
221
+ expressions = []
222
+ args.each do |entry|
266
223
  if entry.is_a?(Hash)
267
- process_hash_entry(entry, out_nodes, negated)
224
+ entry.each do |field, value|
225
+ if join_scope_value?(value) && join_assoc?(field)
226
+ scoped = []
227
+ process_join_scope(field, value, scoped)
228
+ nodes.concat(negated ? scoped.map { |node| invert_predicate(node) } : scoped)
229
+ elsif value.is_a?(Hash)
230
+ SearchEngine::DSL::Parser.validate_assoc_and_join!(@klass, field.to_sym, joins_list)
231
+ value.each do |inner_field, inner_value|
232
+ nodes << hash_predicate(inner_field, inner_value, assoc: field, negated: negated)
233
+ end
234
+ else
235
+ nodes << hash_predicate(field, value, negated: negated)
236
+ end
237
+ end
268
238
  else
269
- non_hash_items << entry
239
+ expressions << entry
270
240
  end
271
241
  end
272
242
 
273
- unless non_hash_items.empty?
274
- out_nodes.concat(
275
- Array(SearchEngine::DSL::Parser.parse_list(non_hash_items, klass: @klass, joins: joins_list))
276
- )
243
+ parsed = SearchEngine::DSL::Parser.parse_list(expressions, klass: @klass, joins: joins_list)
244
+ nodes.concat(negated ? parsed.map { |node| invert_predicate(node) } : parsed)
245
+ end
246
+
247
+ def hash_predicate(key, value, negated:, assoc: nil)
248
+ field = SearchEngine::Filters::Sanitizer.field_name(key).to_sym
249
+ empty_array = value.is_a?(Array) && value.flatten(1).compact.empty?
250
+ if value.nil? || empty_array
251
+ lhs = assoc ? "$#{assoc}.#{field}" : field.to_s
252
+ empty = assoc ? joined_empty_filtering_enabled?(assoc, field) : base_empty_filtering_enabled?(field)
253
+ blank = assoc ? joined_optional_enabled?(assoc, field) : base_optional_enabled?(field)
254
+ raise_empty_array_type!(field) if empty_array && !empty
255
+
256
+ flags = []
257
+ flags << SearchEngine::AST.eq("#{lhs}_empty", !negated) if empty
258
+ flags << SearchEngine::AST.eq("#{lhs}_blank", !negated) if blank && value.nil?
259
+ return flags.first if flags.size == 1
260
+ if flags.size > 1
261
+ return SearchEngine::AST.group(negated ? SearchEngine::AST.and_(*flags) : SearchEngine::AST.or_(*flags))
262
+ end
277
263
  end
278
264
 
279
- out_nodes.flatten.compact
265
+ predicate = { field => value }
266
+ predicate = { assoc => predicate } if assoc
267
+ node = SearchEngine::DSL::Parser.parse(predicate, klass: @klass, joins: joins_list)
268
+ negated ? invert_predicate(node) : node
280
269
  end
281
270
 
282
- def process_hash_entry(entry, out_nodes, negated)
283
- entry.each do |k, v|
284
- # Join-scope shorthand: where(assoc: :scope) or where(assoc: [:s1, :s2])
285
- if join_scope_value?(v) && join_assoc?(k)
286
- process_join_scope(k.to_sym, v, out_nodes)
287
- elsif v.is_a?(Hash)
288
- process_join_predicate(k, v, out_nodes, negated)
289
- else
290
- process_base_predicate(k, v, out_nodes, negated)
291
- end
292
- end
293
- end
271
+ INVERSE_PREDICATES = {
272
+ eq: :not_eq, not_eq: :eq, in: :not_in, not_in: :in_,
273
+ gt: :lte, gte: :lt, lt: :gte, lte: :gt
274
+ }.freeze
275
+ private_constant :INVERSE_PREDICATES
294
276
 
295
- def process_join_predicate(assoc_key, values_hash, out_nodes, negated)
296
- assoc = assoc_key.to_sym
297
- values_hash.each do |inner_field, inner_value|
298
- field_sym = inner_field.to_sym
299
- if inner_value.nil?
300
- emit_nil_flags_for_join(out_nodes, assoc, field_sym, negated)
301
- elsif array_like?(inner_value)
302
- arr = Array(inner_value).flatten(1).compact
303
- if arr.empty?
304
- if joined_empty_filtering_enabled?(assoc, field_sym)
305
- emit_empty_array_flag(out_nodes, "$#{assoc}.#{field_sym}_empty", negated)
306
- else
307
- raise_empty_array_type!(field_sym)
308
- end
309
- else
310
- out_nodes << SearchEngine::DSL::Parser.parse(
311
- { assoc => { field_sym => inner_value } }, klass: @klass, joins: joins_list
312
- )
313
- end
314
- else
315
- out_nodes << SearchEngine::DSL::Parser.parse(
316
- { assoc => { field_sym => inner_value } }, klass: @klass, joins: joins_list
317
- )
318
- end
319
- end
320
- end
277
+ def invert_predicate(node)
278
+ inverse = INVERSE_PREDICATES[node.type]
279
+ return SearchEngine::AST.public_send(inverse, node.field, node.right) if inverse
321
280
 
322
- def process_base_predicate(field_key, value, out_nodes, negated)
323
- field = field_key.to_sym
324
- if value.nil?
325
- emit_nil_flags_for_base(out_nodes, field, negated)
326
- elsif array_like?(value)
327
- arr = Array(value).flatten(1).compact
328
- if arr.empty?
329
- if base_empty_filtering_enabled?(field)
330
- emit_empty_array_flag(out_nodes, "#{field}_empty", negated)
331
- else
332
- raise_empty_array_type!(field)
333
- end
334
- else
335
- out_nodes << SearchEngine::DSL::Parser.parse({ field => value }, klass: @klass, joins: joins_list)
336
- end
281
+ case node
282
+ when SearchEngine::AST::And
283
+ SearchEngine::AST.or_(*node.children.map { |child| invert_predicate(child) })
284
+ when SearchEngine::AST::Or
285
+ SearchEngine::AST.and_(*node.children.map { |child| invert_predicate(child) })
286
+ when SearchEngine::AST::Group
287
+ SearchEngine::AST.group(invert_predicate(node.child))
337
288
  else
338
- out_nodes << SearchEngine::DSL::Parser.parse({ field => value }, klass: @klass, joins: joins_list)
289
+ raise SearchEngine::Errors::InvalidOperator, "where.not cannot invert a #{node.type} predicate"
339
290
  end
340
291
  end
341
292
 
@@ -478,10 +429,6 @@ module SearchEngine
478
429
  end
479
430
  end
480
431
 
481
- def emit_empty_array_flag(out_nodes, lhs, negated)
482
- out_nodes << SearchEngine::AST.raw("#{lhs}:=#{negated ? 'false' : 'true'}")
483
- end
484
-
485
432
  def base_empty_filtering_enabled?(field_sym)
486
433
  opts = @klass.respond_to?(:attribute_options) ? (@klass.attribute_options || {}) : {}
487
434
  o = opts[field_sym]
@@ -526,59 +473,6 @@ module SearchEngine
526
473
  false
527
474
  end
528
475
 
529
- def emit_nil_flags_for_base(out_nodes, field_sym, negated)
530
- has_empty = base_empty_filtering_enabled?(field_sym)
531
- has_blank = base_optional_enabled?(field_sym)
532
- fragment = nil
533
- if has_empty && has_blank
534
- fragment = if negated
535
- "(#{field_sym}_empty:=false && #{field_sym}_blank:=false)"
536
- else
537
- "(#{field_sym}_empty:=true || #{field_sym}_blank:=true)"
538
- end
539
- elsif has_blank
540
- fragment = "#{field_sym}_blank:=#{negated ? 'false' : 'true'}"
541
- elsif has_empty
542
- fragment = "#{field_sym}_empty:=#{negated ? 'false' : 'true'}"
543
- end
544
-
545
- out_nodes << if fragment
546
- SearchEngine::AST.raw(fragment)
547
- else
548
- SearchEngine::DSL::Parser.parse({ field_sym => nil }, klass: @klass, joins: joins_list)
549
- end
550
- end
551
-
552
- def emit_nil_flags_for_join(out_nodes, assoc_sym, field_sym, negated)
553
- has_empty = joined_empty_filtering_enabled?(assoc_sym, field_sym)
554
- has_blank = joined_optional_enabled?(assoc_sym, field_sym)
555
- lhs_empty = "$#{assoc_sym}.#{field_sym}_empty"
556
- lhs_blank = "$#{assoc_sym}.#{field_sym}_blank"
557
- fragment = nil
558
- if has_empty && has_blank
559
- fragment = if negated
560
- "(#{lhs_empty}:=false && #{lhs_blank}:=false)"
561
- else
562
- "(#{lhs_empty}:=true || #{lhs_blank}:=true)"
563
- end
564
- elsif has_blank
565
- fragment = "#{lhs_blank}:=#{negated ? 'false' : 'true'}"
566
- elsif has_empty
567
- fragment = "#{lhs_empty}:=#{negated ? 'false' : 'true'}"
568
- end
569
-
570
- if fragment
571
- out_nodes << SearchEngine::AST.raw(fragment)
572
- else
573
- parsed = { assoc_sym => { field_sym => nil } }
574
- out_nodes << SearchEngine::DSL::Parser.parse(parsed, klass: @klass, joins: joins_list)
575
- end
576
- end
577
-
578
- def array_like?(value)
579
- value.is_a?(Array)
580
- end
581
-
582
476
  def raise_empty_array_type!(field_sym)
583
477
  raise SearchEngine::Errors::InvalidType.new(
584
478
  %(expected #{field_sym.inspect} to be a non-empty Array),
@@ -591,7 +485,7 @@ module SearchEngine
591
485
  def normalize_where(args)
592
486
  # Preserve input shape so template bind arrays stay intact:
593
487
  # where('field IN ?', [1, 2]) => ['field IN ?', [1, 2]]
594
- list = Array(args).compact
488
+ list = Array(args)
595
489
  return [] if list.empty?
596
490
 
597
491
  fragments = []
@@ -601,6 +495,8 @@ module SearchEngine
601
495
  while i < list.length
602
496
  entry = list[i]
603
497
  case entry
498
+ when nil
499
+ i += 1
604
500
  when Hash
605
501
  # Validate only base-like keys here; assoc keys (values as Hash) are handled via AST/Parser
606
502
  # and assoc keys with join-scope shorthand (values as Symbol/Array<Symbol>) are ignored for fragments.
@@ -610,7 +506,7 @@ module SearchEngine
610
506
  base_pairs = base_like_pairs
611
507
  unless base_pairs.empty?
612
508
  fragments.concat(
613
- SearchEngine::Filters::Sanitizer.build_from_hash(base_pairs, known_attrs)
509
+ SearchEngine::Filters::Sanitizer.build_from_hash(base_pairs, known_attrs, klass: @klass)
614
510
  )
615
511
  end
616
512
  i += 1
@@ -632,15 +528,15 @@ module SearchEngine
632
528
  end
633
529
 
634
530
  def normalize_where_process_string!(fragments, entry, list, i)
635
- if entry.match?(/(?<!\\)\?/) # has unescaped placeholders
531
+ needed = SearchEngine::Filters::Sanitizer.count_placeholders(entry)
532
+ if needed.positive?
636
533
  tail = list[(i + 1)..] || []
637
- needed = SearchEngine::Filters::Sanitizer.count_placeholders(entry)
638
534
  args_for_template = tail.first(needed)
639
535
  if args_for_template.length != needed
640
536
  raise ArgumentError, "expected #{needed} args for #{needed} placeholders, got #{args_for_template.length}"
641
537
  end
642
538
 
643
- fragments << SearchEngine::Filters::Sanitizer.apply_placeholders(entry, args_for_template)
539
+ fragments << SearchEngine::Filters::Sanitizer.apply_placeholders(entry, args_for_template, klass: @klass)
644
540
  i + 1 + needed
645
541
  else
646
542
  fragments << entry.to_s
@@ -153,7 +153,9 @@ module SearchEngine
153
153
  seg << 'missing_values' if g[:missing_values]
154
154
  parts << "grouping=#{seg.join(':')}"
155
155
  end
156
- if params.key?(:page) || params.key?(:per_page)
156
+ if params.key?(:offset)
157
+ parts << "offset/limit=#{params[:offset]}/#{params[:limit]}"
158
+ elsif params.key?(:page) || params.key?(:per_page)
157
159
  p = params[:page]
158
160
  per = params[:per_page]
159
161
  parts << "page/per=#{p || ''}/#{per || ''}"
@@ -53,7 +53,8 @@ module SearchEngine
53
53
 
54
54
  # Find the first matching record using where-like inputs.
55
55
  # Accepts the same arguments as `.where` (Hash, String, Array, Symbol),
56
- # applies them if provided, then limits to a single result and returns it.
56
+ # and fetches one hit. Page-based searches start at the first match.
57
+ # Offset-based searches retain their explicit starting offset.
57
58
  #
58
59
  # @param args [Array<Object>] where-compatible arguments
59
60
  # @return [Object, nil]
@@ -61,8 +62,10 @@ module SearchEngine
61
62
  # SearchEngine::Product.find_by(article_code: 12312, store_id: 1031)
62
63
  # SearchEngine::Product.where(active: true).find_by('price:>100')
63
64
  def find_by(*args)
64
- relation = args.nil? || args.empty? ? self : where(*args)
65
- relation.per(1).page(1).first
65
+ relation = where(*args)
66
+ return relation.limit(1).first if compute_pagination.key?(:offset)
67
+
68
+ relation.page(1).per(1).first
66
69
  end
67
70
 
68
71
  # Convenience for plucking :id values.
@@ -5,33 +5,12 @@ module SearchEngine
5
5
  # Options and pagination normalization helpers.
6
6
  # Centralizes precedence rules and guards for page/per vs limit/offset.
7
7
  module Options
8
- # Compute page/per_page with precedence:
9
- # - When page/per are provided, prefer them (fill missing per with nil, page defaults to 1 when only per is set)
10
- # - Otherwise, when limit is set, derive page from offset and per_page from limit
11
- # - Otherwise, return empty Hash
12
- # @return [Hash{Symbol=>Integer}]
8
+ # Page-based pagination takes precedence. A native offset can start between pages.
13
9
  def compute_pagination
14
- page = @state[:page]
15
- per = @state[:per_page]
16
-
17
- if page || per
18
- out = {}
19
- if per && page
20
- out[:page] = page
21
- out[:per_page] = per
22
- elsif per && !page
23
- out[:page] = 1
24
- out[:per_page] = per
25
- elsif page && !per
26
- out[:page] = page
27
- end
28
- return out
29
- elsif @state[:limit]
30
- limit = @state[:limit]
31
- off = @state[:offset] || 0
32
- computed_page = (off.to_i / limit.to_i) + 1
33
- return { page: computed_page, per_page: limit }
34
- end
10
+ page, per, limit, offset = @state.values_at(:page, :per_page, :limit, :offset)
11
+ return { page: page || 1, per_page: per }.compact if page || per
12
+ return { offset: offset, limit: limit }.compact if offset
13
+ return { page: 1, per_page: limit } if limit
35
14
 
36
15
  {}
37
16
  end
@@ -128,8 +128,9 @@ module SearchEngine
128
128
  legacy = Array(state[:filters]).flatten.compact
129
129
  return if !ast_nodes.empty? || legacy.empty?
130
130
 
131
- raw_nodes = legacy.map { |fragment| SearchEngine::AST.raw(String(fragment)) }
132
- state[:ast] = raw_nodes
131
+ state[:ast] = legacy.map { |fragment| SearchEngine::AST.raw(String(fragment)) }
132
+ # On a subsequent spawn, '?' in these compiled literals is data.
133
+ state[:filters] = []
133
134
  nil
134
135
  end
135
136
 
@@ -17,7 +17,7 @@ module SearchEngine
17
17
  find_index index rindex values_at sample shuffle rotate
18
18
  ].freeze
19
19
  # Keys considered essential for :only preset mode.
20
- ESSENTIAL_PARAM_KEYS = %i[q page per_page].freeze
20
+ ESSENTIAL_PARAM_KEYS = %i[q page per_page offset limit].freeze
21
21
 
22
22
  # @return [Class] bound model class (typically a SearchEngine::Base subclass)
23
23
  attr_reader :klass
@@ -463,8 +463,9 @@ module SearchEngine
463
463
  parts << gparts.join(' ')
464
464
  end
465
465
 
466
- parts << "page=#{compiled[:page]}" if compiled.key?(:page)
467
- parts << "per=#{compiled[:per_page]}" if compiled.key?(:per_page)
466
+ { page: :page, per: :per_page, offset: :offset, limit: :limit }.each do |label, key|
467
+ parts << "#{label}=#{compiled[key]}" if compiled.key?(key)
468
+ end
468
469
 
469
470
  "#<#{self.class.name} #{parts.join(' ')} >"
470
471
  end
@@ -586,7 +587,7 @@ module SearchEngine
586
587
  msg,
587
588
  hint: 'Increase `validate_hits!(max:)` or narrow filters. Prefer `limit_hits(n)` to avoid work when supported.',
588
589
  doc: 'https://nikita-shkoda.mintlify.app/projects/search-engine-for-typesense/v30.1/hit-limits#validation',
589
- details: { total_hits: th, max: max, collection: coll, relation_summary: inspect }
590
+ details: { total_hits: th, max: max, collection: coll, relation_summary: summary_inspect_string }
590
591
  )
591
592
  end
592
593
 
@@ -105,6 +105,26 @@ module SearchEngine
105
105
  freeze
106
106
  end
107
107
 
108
+ # Apply relation constraints to a result supplied by a custom client.
109
+ # Matching model instances, including all grouped hits, can be shared.
110
+ def with_context(klass:, selection:, facets:)
111
+ return Result.new(raw, klass: klass, selection: selection, facets: facets) unless klass == @klass
112
+
113
+ copy = dup
114
+ copy.send(:apply_context!, selection, facets)
115
+ copy.freeze
116
+ end
117
+
118
+ # Source documents aligned with #hits, or with every hit in each group.
119
+ def self.documents(raw, all_group_members: false)
120
+ groups = raw['grouped_hits'] || raw[:grouped_hits]
121
+ (groups || [raw]).flat_map do |section|
122
+ hits = section['hits'] || section[:hits]
123
+ docs = Array(hits).filter_map { |hit| hit['document'] || hit[:document] }
124
+ groups && !all_group_members ? docs.first(1) : docs
125
+ end
126
+ end
127
+
108
128
  # Iterate over hydrated hits.
109
129
  # @yieldparam obj [Object] hydrated object
110
130
  # @return [Enumerator] when no block is given
@@ -314,6 +334,17 @@ module SearchEngine
314
334
  end
315
335
  end
316
336
 
337
+ def apply_context!(selection, facets)
338
+ @selection_ctx = selection
339
+ if selection && selection[:strict_missing]
340
+ Result.documents(raw, all_group_members: true).each do |document|
341
+ enforce_strict_missing_if_needed!(document.keys.map(&:to_s))
342
+ end
343
+ end
344
+ @facets_ctx = facets
345
+ @__facets_parsed_memo = build_parsed_facets(raw, facets).freeze if facets
346
+ end
347
+
317
348
  def parse_facets
318
349
  @__facets_parsed_memo || {}.freeze
319
350
  end
@@ -504,12 +535,12 @@ module SearchEngine
504
535
 
505
536
  # Enforce strict-missing behavior when enabled.
506
537
  # Computes missing = requested_root − present_keys and raises when non-empty.
507
- def enforce_strict_missing_if_needed!(present_keys)
538
+ def enforce_strict_missing_if_needed!(present_keys, requested_override: nil)
508
539
  ctx = @selection_ctx || {}
509
540
  strict = (ctx[:strict_missing] == true)
510
541
  return unless strict
511
542
 
512
- requested = Array(ctx[:requested_root]).map(&:to_s).reject(&:empty?)
543
+ requested = Array(requested_override || ctx[:requested_root]).map(&:to_s).reject(&:empty?)
513
544
  return if requested.empty?
514
545
 
515
546
  missing = requested - present_keys
@@ -161,9 +161,9 @@ module SearchEngine
161
161
  # physical name). If no block is given, and the klass responds to
162
162
  # `reindex_all_to(physical_name)`, that method will be called. If neither is available,
163
163
  # an ArgumentError is raised and no alias swap occurs. If reindexing fails or
164
- # is interrupted, the newly created physical collection is automatically deleted
165
- # to prevent orphan accumulation. Retention cleanup only runs after a successful
166
- # alias swap.
164
+ # is interrupted, the unused new physical is cleaned up. Once an alias swap is
165
+ # attempted, an error can leave its outcome unknown. Both generations are kept
166
+ # for reconciliation. Retention cleanup only runs after a successful cutover.
167
167
  #
168
168
  # @param klass [Class] model class inheriting from {SearchEngine::Base}
169
169
  # @param client [SearchEngine::Client] optional client wrapper (for tests)
@@ -201,8 +201,11 @@ module SearchEngine
201
201
  end
202
202
  end
203
203
 
204
- def with_around_rebuild(logical)
204
+ def with_around_rebuild(logical, require_guard: false)
205
205
  guard = SearchEngine.config.schema.around_rebuild
206
+ if require_guard && !guard
207
+ raise ArgumentError, 'rollback expected_current requires schema.around_rebuild to serialize alias writers'
208
+ end
206
209
  return yield unless guard
207
210
 
208
211
  result = nil
@@ -237,10 +240,6 @@ module SearchEngine
237
240
  private :with_around_rebuild
238
241
 
239
242
  def perform_full_rebuild!(klass, client:, compiled:, logical:, reindex_step:)
240
- new_physical = nil
241
- physical_created = false
242
- apply_succeeded = false
243
-
244
243
  cleanup_logical_collection_conflict!(logical, client: client)
245
244
 
246
245
  start_ms = monotonic_ms
@@ -260,7 +259,7 @@ module SearchEngine
260
259
  end
261
260
 
262
261
  client.create_collection(create_schema)
263
- physical_created = true
262
+ cleanup_physical = new_physical
264
263
 
265
264
  if reindex_step
266
265
  reindex_step.call(new_physical)
@@ -270,11 +269,11 @@ module SearchEngine
270
269
  raise ArgumentError, 'reindex step is required: provide a block or implement klass.reindex_all_to(name)'
271
270
  end
272
271
 
273
- # Idempotent: if alias already points to new physical, treat as no-op
274
272
  current_after_reindex = client.resolve_alias(logical)
275
273
  swapped = current_after_reindex != new_physical
274
+ # A timed-out alias PUT may still commit, even after a read sees the old target.
275
+ cleanup_physical = nil
276
276
  client.upsert_alias(logical, new_physical) if swapped
277
- apply_succeeded = true
278
277
 
279
278
  # Retention cleanup
280
279
  _, dropped = enforce_retention!(logical, new_physical, client: client, keep_last: effective_keep_last(klass))
@@ -305,7 +304,7 @@ module SearchEngine
305
304
  action: :rebuild
306
305
  }
307
306
  ensure
308
- cleanup_interrupted_physical!(new_physical, client) if physical_created && !apply_succeeded
307
+ cleanup_interrupted_physical!(logical, cleanup_physical, client) if cleanup_physical
309
308
  end
310
309
  private :perform_full_rebuild!
311
310
 
@@ -314,8 +313,9 @@ module SearchEngine
314
313
  # With no explicit target, the safe legacy form only rolls the newest physical
315
314
  # back one generation. Repeating the same call is a no-op instead of toggling
316
315
  # the alias forward again. Use +to+ when orphaned/newer physicals make the
317
- # implicit destination ambiguous. +expected_current+ adds compare-and-swap
318
- # protection; a retry that already reached +to+ remains successful.
316
+ # implicit destination ambiguous. +expected_current+ requires an exclusive
317
+ # +schema.around_rebuild+ guard shared by all alias writers across processes.
318
+ # A retry that already reached +to+ remains successful.
319
319
  #
320
320
  # Alias resolution, validation, and the swap all run inside
321
321
  # +schema.around_rebuild+ when configured, so delivery claims and cooperating
@@ -335,7 +335,7 @@ module SearchEngine
335
335
  destination = normalize_rollback_target(to, 'to')
336
336
  expected = normalize_rollback_target(expected_current, 'expected_current')
337
337
 
338
- with_around_rebuild(logical) do
338
+ with_around_rebuild(logical, require_guard: !expected.nil?) do
339
339
  perform_rollback!(
340
340
  logical,
341
341
  client: client,
@@ -615,16 +615,15 @@ module SearchEngine
615
615
 
616
616
  private
617
617
 
618
- # Best-effort cleanup of a physical collection left behind by an interrupted
619
- # or failed {.apply!}. Rescues all errors so it never masks the original
620
- # exception. If the delete itself fails, a warning directs users to
621
- # {.prune_orphans!} as a fallback.
618
+ # Clean up a failed rebuild before cutover only if the alias is known to
619
+ # point elsewhere. Preserve the original exception if inspection or deletion fails.
622
620
  #
623
- # @param physical_name [String, nil] the physical collection to remove
624
- # @param client [SearchEngine::Client, nil]
621
+ # @param logical [String] alias for the rebuilt collection
622
+ # @param physical_name [String] the physical collection to remove
623
+ # @param client [SearchEngine::Client]
625
624
  # @return [void]
626
- def cleanup_interrupted_physical!(physical_name, client)
627
- return if physical_name.nil? || client.nil?
625
+ def cleanup_interrupted_physical!(logical, physical_name, client)
626
+ return if client.resolve_alias(logical) == physical_name
628
627
 
629
628
  client.delete_collection(physical_name, timeout_ms: 60_000)
630
629
 
@@ -637,7 +636,7 @@ module SearchEngine
637
636
  rescue StandardError => error
638
637
  warn(
639
638
  'SearchEngine::Schema — failed to clean up interrupted physical ' \
640
- "'#{physical_name}': #{error.message}. Run prune_orphans! to remove it."
639
+ "'#{physical_name}': #{error.message}. Verify alias '#{logical}' before pruning unused generations."
641
640
  )
642
641
  end
643
642
 
@@ -13,11 +13,11 @@ module SearchEngine
13
13
  EMPTY_SEARCH = { 'hits' => [], 'found' => 0, 'out_of' => 0 }.freeze
14
14
 
15
15
  def search(collection:, params:, url_opts: {})
16
- SearchEngine::Result.new(EMPTY_SEARCH, klass: nil)
16
+ block_given? ? yield(EMPTY_SEARCH) : SearchEngine::Result.new(EMPTY_SEARCH, klass: nil)
17
17
  end
18
18
 
19
19
  def multi_search(searches:, url_opts: {})
20
- { 'results' => [] }
20
+ { 'results' => searches.map { EMPTY_SEARCH } }
21
21
  end
22
22
 
23
23
  def import_documents(collection:, jsonl:, action: :upsert)