parse-stack-next 4.5.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,353 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module AtlasSearch
6
+ # Manages Atlas Search index discovery and caching.
7
+ # Uses $listSearchIndexes aggregation stage to discover available indexes.
8
+ #
9
+ # The cache is process-local, time-bounded (default 300 seconds), and
10
+ # protected by a Mutex. Override the TTL via:
11
+ #
12
+ # Parse::AtlasSearch::IndexManager.cache_ttl = 60 # seconds
13
+ #
14
+ # @example List indexes
15
+ # indexes = Parse::AtlasSearch::IndexManager.list_indexes("Song")
16
+ # # => [{"name" => "default", "status" => "READY", ...}]
17
+ #
18
+ # @example Check if index is ready
19
+ # IndexManager.index_ready?("Song", "song_search")
20
+ # # => true
21
+ module IndexManager
22
+ # Default cache TTL in seconds. Index definitions rarely change at
23
+ # runtime, but new indexes built via the Atlas UI should become
24
+ # visible without a process restart.
25
+ DEFAULT_CACHE_TTL = 300
26
+
27
+ class << self
28
+ # @return [Numeric] the cache TTL in seconds. Set to 0 or negative
29
+ # to disable caching entirely.
30
+ attr_writer :cache_ttl
31
+
32
+ def cache_ttl
33
+ @cache_ttl || DEFAULT_CACHE_TTL
34
+ end
35
+
36
+ # List all search indexes for a collection (cached).
37
+ # Uses the $listSearchIndexes aggregation stage.
38
+ #
39
+ # @param collection_name [String] the Parse collection name
40
+ # @param force_refresh [Boolean] bypass cache and fetch fresh data
41
+ # @return [Array<Hash>] array of index definitions with keys:
42
+ # - id: String - the index ID
43
+ # - name: String - the index name
44
+ # - status: String - "READY", "BUILDING", etc.
45
+ # - queryable: Boolean - whether the index is queryable
46
+ # - mappings: Hash - field mappings definition
47
+ def list_indexes(collection_name, force_refresh: false)
48
+ if !force_refresh
49
+ cached = cache_mutex.synchronize do
50
+ cached_indexes(collection_name) if cache_valid?(collection_name)
51
+ end
52
+ return cached if cached
53
+ end
54
+
55
+ # $listSearchIndexes must be the first and only stage in pipeline
56
+ pipeline = [{ "$listSearchIndexes" => {} }]
57
+
58
+ begin
59
+ # `$listSearchIndexes` returns server-side index metadata,
60
+ # not document rows. CLP gates row access ("find") and is
61
+ # not the right gate for "what indexes exist on this
62
+ # collection" — every code path that introspects index
63
+ # state (`Model.describe`, the migrator, `wait_for_ready`)
64
+ # would otherwise refuse under any scoped agent. Pass
65
+ # `master: true` so the SDK's CLP layer skips this metadata
66
+ # pipeline. The mongo-side privilege check still applies
67
+ # (the underlying connection must hold `listSearchIndexes`).
68
+ results = Parse::MongoDB.aggregate(collection_name, pipeline, master: true)
69
+ cache_mutex.synchronize { cache_indexes(collection_name, results) }
70
+ results
71
+ rescue => e
72
+ handle_list_error(e, collection_name)
73
+ end
74
+ end
75
+
76
+ # Check if a search index exists for a collection
77
+ # @param collection_name [String] the Parse collection name
78
+ # @param index_name [String] the index name to check
79
+ # @return [Boolean] true if index exists
80
+ def index_exists?(collection_name, index_name)
81
+ indexes = list_indexes(collection_name)
82
+ indexes.any? { |idx| idx["name"] == index_name }
83
+ end
84
+
85
+ # Check if a search index exists and is ready to query
86
+ # @param collection_name [String] the Parse collection name
87
+ # @param index_name [String] the index name to check
88
+ # @return [Boolean] true if index exists and is queryable
89
+ def index_ready?(collection_name, index_name)
90
+ indexes = list_indexes(collection_name)
91
+ index = indexes.find { |idx| idx["name"] == index_name }
92
+ index.present? && index["queryable"] == true
93
+ end
94
+
95
+ # Get a specific index definition
96
+ # @param collection_name [String] the Parse collection name
97
+ # @param index_name [String] the index name
98
+ # @return [Hash, nil] the index definition or nil if not found
99
+ def get_index(collection_name, index_name)
100
+ indexes = list_indexes(collection_name)
101
+ indexes.find { |idx| idx["name"] == index_name }
102
+ end
103
+
104
+ # Validate that an index exists and is ready
105
+ # @param collection_name [String] the Parse collection name
106
+ # @param index_name [String] the index name to validate
107
+ # @raise [IndexNotFound] if the index doesn't exist or isn't ready
108
+ def validate_index!(collection_name, index_name)
109
+ unless index_ready?(collection_name, index_name)
110
+ available = list_indexes(collection_name).map { |i| i["name"] }.join(", ")
111
+ raise IndexNotFound,
112
+ "Atlas Search index '#{index_name}' not found or not ready on collection '#{collection_name}'. " \
113
+ "Available indexes: #{available.presence || "none"}"
114
+ end
115
+ end
116
+
117
+ # Create an Atlas Search index on a collection and invalidate the
118
+ # local cache so subsequent {.index_exists?}/{.index_ready?}
119
+ # observations reflect the new index. Thin wrapper over
120
+ # {Parse::MongoDB.create_search_index} — triple-gated, idempotent
121
+ # on name, asynchronous on the Atlas Search node.
122
+ #
123
+ # The build runs in the background. Poll {.index_ready?} to
124
+ # confirm the index has transitioned to `READY` before issuing
125
+ # queries against it.
126
+ #
127
+ # @param collection_name [String] target collection / Parse class
128
+ # @param index_name [String] the search index name
129
+ # @param definition [Hash] the search index definition, e.g.
130
+ # `{ mappings: { dynamic: true } }` or
131
+ # `{ mappings: { fields: { title: { type: "string" } } } }`
132
+ # @param allow_system_classes [Boolean] opt-in for Parse-internal
133
+ # @return [Symbol] `:created` on submission, `:exists` if already present
134
+ def create_index(collection_name, index_name, definition, allow_system_classes: false)
135
+ result = Parse::MongoDB.create_search_index(
136
+ collection_name, index_name, definition,
137
+ allow_system_classes: allow_system_classes,
138
+ )
139
+ clear_cache(collection_name)
140
+ result
141
+ end
142
+
143
+ # Drop an Atlas Search index by name and invalidate the local
144
+ # cache. Confirm token is `"drop_search:#{collection}:#{name}"`
145
+ # — distinct from {Parse::MongoDB.drop_index}'s `"drop:"` prefix.
146
+ #
147
+ # @param collection_name [String]
148
+ # @param index_name [String]
149
+ # @param confirm [String] must equal `"drop_search:#{collection}:#{index_name}"`
150
+ # @param allow_system_classes [Boolean]
151
+ # @return [Symbol] `:dropped` or `:absent`
152
+ def drop_index(collection_name, index_name, confirm:, allow_system_classes: false)
153
+ result = Parse::MongoDB.drop_search_index(
154
+ collection_name, index_name, confirm: confirm,
155
+ allow_system_classes: allow_system_classes,
156
+ )
157
+ clear_cache(collection_name)
158
+ result
159
+ end
160
+
161
+ # Replace the definition of an existing Atlas Search index and
162
+ # invalidate the local cache. The rebuild runs asynchronously;
163
+ # the new mapping is not live until {.index_ready?} returns true
164
+ # again.
165
+ #
166
+ # @param collection_name [String]
167
+ # @param index_name [String]
168
+ # @param definition [Hash] replacement definition
169
+ # @param allow_system_classes [Boolean]
170
+ # @return [Symbol] `:updated`
171
+ def update_index(collection_name, index_name, definition, allow_system_classes: false)
172
+ result = Parse::MongoDB.update_search_index(
173
+ collection_name, index_name, definition,
174
+ allow_system_classes: allow_system_classes,
175
+ )
176
+ clear_cache(collection_name)
177
+ result
178
+ end
179
+
180
+ # Block until a search index reaches `READY` (queryable) status,
181
+ # the build fails, or the timeout elapses. Bypasses the
182
+ # IndexManager's 300-second cache via `force_refresh: true` on
183
+ # every poll — naive callers using `until index_ready?; sleep`
184
+ # cache the `BUILDING` state for the full TTL and never see the
185
+ # transition to `READY`. This helper is the correct path.
186
+ #
187
+ # **Resilience to transient connectivity loss.** Atlas Local's
188
+ # internal supervisor periodically restarts `mongod` (5-10s
189
+ # outage windows during replica-set sync events). If a poll
190
+ # lands in a restart window, the underlying `$listSearchIndexes`
191
+ # call raises `Mongo::Error::NoServerAvailable` (or surfaces it
192
+ # via `Parse::AtlasSearch::NotAvailable`). The poll treats those
193
+ # as transient and continues until the deadline — only the
194
+ # final deadline-elapsed condition produces `:timeout`. A non-
195
+ # transient error (e.g. an Atlas-side `FAILED` status surfaced
196
+ # through some other exception class) still raises out.
197
+ #
198
+ # @param collection_name [String]
199
+ # @param index_name [String]
200
+ # @param timeout [Numeric] seconds to wait before returning
201
+ # `:timeout`. Default 600 (10 minutes).
202
+ # @param interval [Numeric] seconds between polls. Default 5.
203
+ # @return [Symbol] `:ready` once the index is queryable,
204
+ # `:failed` when the index reports a `FAILED` status,
205
+ # `:timeout` when the deadline elapses without either.
206
+ def wait_for_ready(collection_name, index_name, timeout: 600, interval: 5)
207
+ deadline = Time.now + timeout
208
+ # Cap consecutive transient failures. The intent of the
209
+ # resilience is to bridge a single mongod-restart window
210
+ # (5-10s); a sustained failure of 25+ seconds is a real outage,
211
+ # not a restart, and should raise rather than loop until the
212
+ # caller's full timeout elapses (which can be 10+ minutes for
213
+ # large-build callers).
214
+ #
215
+ # `interval <= 0` is a unit-test affordance (tests stub `sleep`
216
+ # to a no-op and pass `interval: 0` so the suite isn't paced by
217
+ # real wall-clock waits). Dividing 25.0 by zero produces
218
+ # Infinity, and `Float#ceil` on Infinity raises
219
+ # `FloatDomainError`, so guard the divisor with a small
220
+ # positive epsilon. The clamp upper bound (12) is what the
221
+ # formula resolves to in that case, which is the right answer
222
+ # — with no inter-poll delay, the consecutive-failure counter
223
+ # is the only thing bounding the loop, and the upper bound is
224
+ # the most permissive setting.
225
+ divisor = interval > 0 ? interval.to_f : 0.001
226
+ max_consecutive_transient = (25.0 / divisor).ceil.clamp(3, 12)
227
+ consecutive_transient = 0
228
+ last_transient = nil
229
+ loop do
230
+ indexes = begin
231
+ last_transient = nil
232
+ list_indexes(collection_name, force_refresh: true)
233
+ rescue Parse::AtlasSearch::NotAvailable, StandardError => e
234
+ raise unless transient_poll_error?(e)
235
+ last_transient = e
236
+ nil
237
+ end
238
+ if indexes
239
+ consecutive_transient = 0
240
+ idx = indexes.find { |i| (i["name"] || i[:name]).to_s == index_name.to_s }
241
+ if idx
242
+ return :ready if idx["queryable"] == true
243
+ status = (idx["status"] || idx[:status]).to_s.upcase
244
+ return :failed if status == "FAILED"
245
+ end
246
+ else
247
+ consecutive_transient += 1
248
+ if consecutive_transient >= max_consecutive_transient
249
+ raise last_transient
250
+ end
251
+ end
252
+ return :timeout if Time.now >= deadline
253
+ sleep interval
254
+ end
255
+ end
256
+
257
+ # Clear the index cache
258
+ # @param collection_name [String, nil] specific collection to clear, or nil for all
259
+ def clear_cache(collection_name = nil)
260
+ cache_mutex.synchronize do
261
+ if collection_name
262
+ index_cache.delete(collection_name)
263
+ else
264
+ @index_cache = {}
265
+ end
266
+ end
267
+ end
268
+
269
+ private
270
+
271
+ # Class names (string-matched to avoid hard-requiring the mongo gem
272
+ # in environments where Atlas Search isn't used) and error-message
273
+ # substrings that indicate a transient connectivity loss: typically
274
+ # mongodb-atlas-local's supervisor cycling `mongod` for replica-set
275
+ # sync. wait_for_ready treats these as "keep polling" rather than
276
+ # propagating. Real errors (auth, permission, programmer bugs) fall
277
+ # through and raise.
278
+ TRANSIENT_POLL_ERROR_CLASS_NAMES = %w[
279
+ Mongo::Error::NoServerAvailable
280
+ Mongo::Error::SocketError
281
+ Mongo::Error::SocketTimeoutError
282
+ Mongo::Error::ServerSelectionError
283
+ Parse::AtlasSearch::NotAvailable
284
+ ].to_set.freeze
285
+ private_constant :TRANSIENT_POLL_ERROR_CLASS_NAMES
286
+
287
+ TRANSIENT_POLL_ERROR_MESSAGE_FRAGMENTS = [
288
+ "no primary",
289
+ "connection refused",
290
+ "not available",
291
+ "host unreachable",
292
+ "no server",
293
+ "could not connect",
294
+ ].freeze
295
+ private_constant :TRANSIENT_POLL_ERROR_MESSAGE_FRAGMENTS
296
+
297
+ def transient_poll_error?(err)
298
+ return true if TRANSIENT_POLL_ERROR_CLASS_NAMES.include?(err.class.name)
299
+ msg = err.message.to_s.downcase
300
+ TRANSIENT_POLL_ERROR_MESSAGE_FRAGMENTS.any? { |fragment| msg.include?(fragment) }
301
+ end
302
+
303
+ # Mutex protecting @index_cache. Initialized lazily but the
304
+ # initialization itself is guarded by a class-level mutex created at
305
+ # load time, so two threads can't race on first access.
306
+ CACHE_MUTEX_INIT = Mutex.new
307
+ private_constant :CACHE_MUTEX_INIT
308
+
309
+ def cache_mutex
310
+ @cache_mutex ||= CACHE_MUTEX_INIT.synchronize { @cache_mutex ||= Mutex.new }
311
+ end
312
+
313
+ def index_cache
314
+ @index_cache ||= {}
315
+ end
316
+
317
+ def cached_indexes(collection_name)
318
+ index_cache.dig(collection_name, :indexes) || []
319
+ end
320
+
321
+ def cache_valid?(collection_name)
322
+ entry = index_cache[collection_name]
323
+ return false unless entry
324
+ ttl = cache_ttl
325
+ return false if ttl <= 0
326
+ (Time.now - entry[:cached_at]) < ttl
327
+ end
328
+
329
+ def cache_indexes(collection_name, indexes)
330
+ index_cache[collection_name] = {
331
+ indexes: indexes,
332
+ cached_at: Time.now,
333
+ }
334
+ end
335
+
336
+ def handle_list_error(error, collection_name)
337
+ msg = error.message.to_s.downcase
338
+ if msg.include?("not available") ||
339
+ msg.include?("atlas") ||
340
+ msg.include?("command not found") ||
341
+ msg.include?("unrecognized") ||
342
+ msg.include?("not supported")
343
+ raise NotAvailable,
344
+ "Atlas Search is not available for collection '#{collection_name}'. " \
345
+ "Ensure you're using MongoDB Atlas with Search enabled, or a local Atlas deployment. " \
346
+ "Original error: #{error.message}"
347
+ end
348
+ raise error
349
+ end
350
+ end
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,204 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module AtlasSearch
6
+ # Result container for full-text search operations.
7
+ # Provides access to results with relevance scores.
8
+ #
9
+ # @example Iterating results
10
+ # result = Parse::AtlasSearch.search("Song", "love")
11
+ # result.each do |song|
12
+ # puts "#{song.title} (score: #{song.search_score})"
13
+ # end
14
+ #
15
+ # @example Checking results
16
+ # result.empty? # => false
17
+ # result.count # => 25
18
+ class SearchResult
19
+ include Enumerable
20
+
21
+ # @return [Array<Parse::Object>] the search results (Parse objects or raw hashes)
22
+ attr_reader :results
23
+
24
+ # @return [Array<Hash>] the raw MongoDB documents
25
+ attr_reader :raw_results
26
+
27
+ # @param results [Array] the processed search results
28
+ # @param raw_results [Array<Hash>] the raw MongoDB documents
29
+ def initialize(results:, raw_results: nil)
30
+ @results = results
31
+ @raw_results = raw_results || results
32
+ end
33
+
34
+ # @return [Integer] the number of results
35
+ def count
36
+ @results.size
37
+ end
38
+
39
+ alias_method :size, :count
40
+ alias_method :length, :count
41
+
42
+ # @return [Boolean] true if there are no results
43
+ def empty?
44
+ @results.empty?
45
+ end
46
+
47
+ # Iterate over results
48
+ # @yield [Object] each result object
49
+ def each(&block)
50
+ @results.each(&block)
51
+ end
52
+
53
+ # @return [Object, nil] the first result
54
+ def first
55
+ @results.first
56
+ end
57
+
58
+ # @return [Object, nil] the last result
59
+ def last
60
+ @results.last
61
+ end
62
+
63
+ # Access result by index
64
+ # @param index [Integer] the index
65
+ # @return [Object, nil] the result at the index
66
+ def [](index)
67
+ @results[index]
68
+ end
69
+
70
+ # @return [Array] the results as an array
71
+ def to_a
72
+ @results.to_a
73
+ end
74
+ end
75
+
76
+ # Result container for autocomplete search operations.
77
+ # Provides both suggestions (field values) and full objects.
78
+ #
79
+ # @example Using suggestions
80
+ # result = Parse::AtlasSearch.autocomplete("Song", "lov", field: :title)
81
+ # result.suggestions # => ["Love Story", "Lovely Day", "Love Me Do"]
82
+ #
83
+ # @example Accessing full objects
84
+ # result.results.each do |song|
85
+ # puts "#{song.title} by #{song.artist}"
86
+ # end
87
+ class AutocompleteResult
88
+ # @return [Array<String>] the autocomplete suggestions (field values)
89
+ attr_reader :suggestions
90
+
91
+ # @return [Array<Parse::Object>] the full Parse objects
92
+ attr_reader :results
93
+
94
+ # @param suggestions [Array<String>] the autocomplete suggestions
95
+ # @param results [Array] the full Parse objects
96
+ def initialize(suggestions:, results:)
97
+ @suggestions = suggestions
98
+ @results = results
99
+ end
100
+
101
+ # @return [Integer] the number of suggestions
102
+ def count
103
+ @suggestions.size
104
+ end
105
+
106
+ alias_method :size, :count
107
+
108
+ # @return [Boolean] true if there are no suggestions
109
+ def empty?
110
+ @suggestions.empty?
111
+ end
112
+
113
+ # Iterate over suggestions
114
+ # @yield [String] each suggestion
115
+ def each(&block)
116
+ @suggestions.each(&block)
117
+ end
118
+
119
+ # @return [String, nil] the first suggestion
120
+ def first
121
+ @suggestions.first
122
+ end
123
+
124
+ # @return [Array<String>] the suggestions as an array
125
+ def to_a
126
+ @suggestions.to_a
127
+ end
128
+ end
129
+
130
+ # Result container for faceted search operations.
131
+ # Provides results, facet counts, and total count.
132
+ #
133
+ # @example Using facets
134
+ # result = Parse::AtlasSearch.faceted_search("Song", "rock", facets)
135
+ # result.facets[:genre].each do |bucket|
136
+ # puts "#{bucket[:value]}: #{bucket[:count]}"
137
+ # end
138
+ #
139
+ # @example Total count
140
+ # puts "Total matches: #{result.total_count}"
141
+ class FacetedResult
142
+ include Enumerable
143
+
144
+ # @return [Array<Parse::Object>] the search results
145
+ attr_reader :results
146
+
147
+ # @return [Hash] the facet results with counts
148
+ # Format: { facet_name: [{ value: "value", count: 123 }, ...] }
149
+ attr_reader :facets
150
+
151
+ # @return [Integer] the total number of matching documents
152
+ attr_reader :total_count
153
+
154
+ # @param results [Array] the search results
155
+ # @param facets [Hash] the facet results
156
+ # @param total_count [Integer] the total matching document count
157
+ def initialize(results:, facets:, total_count:)
158
+ @results = results
159
+ @facets = facets
160
+ @total_count = total_count
161
+ end
162
+
163
+ # @return [Integer] the number of returned results
164
+ def count
165
+ @results.size
166
+ end
167
+
168
+ alias_method :size, :count
169
+
170
+ # @return [Boolean] true if there are no results
171
+ def empty?
172
+ @results.empty?
173
+ end
174
+
175
+ # Iterate over results
176
+ # @yield [Object] each result object
177
+ def each(&block)
178
+ @results.each(&block)
179
+ end
180
+
181
+ # @return [Object, nil] the first result
182
+ def first
183
+ @results.first
184
+ end
185
+
186
+ # Get facet buckets for a specific facet
187
+ # @param name [Symbol, String] the facet name
188
+ # @return [Array<Hash>, nil] the facet buckets or nil if facet doesn't exist
189
+ def facet(name)
190
+ @facets[name.to_sym] || @facets[name.to_s]
191
+ end
192
+
193
+ # @return [Array<Symbol>] the available facet names
194
+ def facet_names
195
+ @facets.keys
196
+ end
197
+
198
+ # @return [Array] the results as an array
199
+ def to_a
200
+ @results.to_a
201
+ end
202
+ end
203
+ end
204
+ end