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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- 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
|