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,291 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Schema
|
|
6
|
+
# Reconciliation engine for `Parse::Core::Indexing` declarations vs.
|
|
7
|
+
# the actual MongoDB index state. Reads existing indexes via the
|
|
8
|
+
# reader connection (so `plan` works in dry-run mode without writer
|
|
9
|
+
# config); applies via the writer connection through
|
|
10
|
+
# {Parse::MongoDB.create_index} / {Parse::MongoDB.drop_index} (which
|
|
11
|
+
# re-check the triple gate and run their own per-call idempotency
|
|
12
|
+
# check on the writer-side index list).
|
|
13
|
+
#
|
|
14
|
+
# **Multi-collection.** A single model can declare indexes against
|
|
15
|
+
# both its own collection (via `mongo_index`) and one or more
|
|
16
|
+
# `_Join:<field>:<ParentClass>` collections (via
|
|
17
|
+
# `mongo_relation_index`). `plan` returns a Hash keyed by collection
|
|
18
|
+
# name with one entry per unique target collection across the
|
|
19
|
+
# declaration list. `apply!` returns a similarly-keyed result Hash.
|
|
20
|
+
#
|
|
21
|
+
# Parse-managed indexes (the ones Parse Server auto-creates on
|
|
22
|
+
# collections like `_User`, `_Session`, `_Role`) are never proposed
|
|
23
|
+
# for drop, regardless of whether they appear in the declaration
|
|
24
|
+
# list. The list is conservative — any name matching
|
|
25
|
+
# {PARSE_MANAGED_INDEX_PATTERNS} is treated as off-limits to the
|
|
26
|
+
# migrator, full stop.
|
|
27
|
+
class IndexMigrator
|
|
28
|
+
# Names / patterns of indexes Parse Server creates and owns. The
|
|
29
|
+
# migrator excludes these from both `to_drop` (so a missing
|
|
30
|
+
# declaration never proposes their removal) and from `in_sync`
|
|
31
|
+
# (so they don't visually clutter operator review). They appear
|
|
32
|
+
# in `parse_managed:` for transparency.
|
|
33
|
+
#
|
|
34
|
+
# **Coverage is not forward-compatible.** This list reflects the
|
|
35
|
+
# indexes Parse Server auto-creates as of Parse Server 7.x. Any
|
|
36
|
+
# future Parse Server release that adds a new managed index will
|
|
37
|
+
# cause that index to be classified as an orphan and be eligible
|
|
38
|
+
# for drop under `DROP=true`. Operators upgrading Parse Server
|
|
39
|
+
# should re-review this list before re-running
|
|
40
|
+
# `parse:mongo:indexes:apply` with the drop flag.
|
|
41
|
+
#
|
|
42
|
+
# Any non-declared, non-managed index — including DBA-created
|
|
43
|
+
# diagnostic indexes, indexes created by other Parse SDKs, and
|
|
44
|
+
# MongoDB Atlas index recommendations — is also classified as
|
|
45
|
+
# an orphan. If you need to preserve such an index, declare it
|
|
46
|
+
# via {Parse::Core::Indexing#mongo_index} on the model.
|
|
47
|
+
PARSE_MANAGED_INDEX_PATTERNS = [
|
|
48
|
+
/\A_id_\z/,
|
|
49
|
+
/\A_username_unique\z/,
|
|
50
|
+
/\A_email_unique\z/,
|
|
51
|
+
/\Aemail_1\z/,
|
|
52
|
+
/\Ausername_1\z/,
|
|
53
|
+
/\A_session_token_/,
|
|
54
|
+
/\A_email_verify_token_/,
|
|
55
|
+
/\A_perishable_token_/,
|
|
56
|
+
/\A_account_lockout_/,
|
|
57
|
+
/\Acase_insensitive_/,
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
attr_reader :model_class
|
|
61
|
+
|
|
62
|
+
def initialize(model_class)
|
|
63
|
+
unless model_class.is_a?(Class) && model_class < Parse::Object
|
|
64
|
+
raise ArgumentError, "IndexMigrator expects a Parse::Object subclass; got #{model_class.inspect}"
|
|
65
|
+
end
|
|
66
|
+
@model_class = model_class
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [String] the model's primary collection name (parse_class).
|
|
70
|
+
def collection_name
|
|
71
|
+
@model_class.parse_class
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array<String>] unique target collections across all
|
|
75
|
+
# declarations. Includes the parent collection only when at
|
|
76
|
+
# least one declaration targets it (i.e. a non-relation index).
|
|
77
|
+
def target_collections
|
|
78
|
+
@model_class.mongo_index_declarations.map { |d| d[:collection] || collection_name }.uniq
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Compute the plan: what would change if `apply!` ran now.
|
|
82
|
+
#
|
|
83
|
+
# @return [Hash{String => Hash}] keyed by collection name. Each
|
|
84
|
+
# value Hash carries the per-collection result (see
|
|
85
|
+
# {#plan_for}).
|
|
86
|
+
def plan
|
|
87
|
+
target_collections.each_with_object({}) do |coll, h|
|
|
88
|
+
h[coll] = plan_for(coll)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Per-collection plan. Filters declarations to those targeting
|
|
93
|
+
# `collection`, then runs the diff against the actual MongoDB
|
|
94
|
+
# state for that collection.
|
|
95
|
+
#
|
|
96
|
+
# Capacity accounting reports two scenarios so callers can reason
|
|
97
|
+
# about both apply modes from a single plan:
|
|
98
|
+
# - `:capacity_after`, `:capacity_ok` — additive-only mode
|
|
99
|
+
# (no drops). Equal to `used + to_create.size`.
|
|
100
|
+
# - `:capacity_after_with_drop`, `:capacity_ok_with_drop` —
|
|
101
|
+
# additive + orphan removal. Equal to `used + to_create.size
|
|
102
|
+
# - orphans.size`. Use these when planning an `apply!(drop:
|
|
103
|
+
# true)` call.
|
|
104
|
+
#
|
|
105
|
+
# @param collection [String] target collection name
|
|
106
|
+
# @return [Hash] per-collection plan
|
|
107
|
+
def plan_for(collection)
|
|
108
|
+
existing = fetch_existing_indexes(collection)
|
|
109
|
+
declared = declarations_for(collection)
|
|
110
|
+
managed, ours = partition_parse_managed(existing)
|
|
111
|
+
to_create, in_sync, conflicts = diff_declarations(declared, ours)
|
|
112
|
+
declared_names = declared.map { |d| d[:options][:name] }.compact.to_set
|
|
113
|
+
declared_sigs = declared.map { |d| key_sig(d[:keys]) }.to_set
|
|
114
|
+
orphans = ours.reject do |idx|
|
|
115
|
+
declared_sigs.include?(key_sig(idx["key"] || idx[:key])) ||
|
|
116
|
+
declared_names.include?(idx["name"] || idx[:name])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
max = Parse::Core::Indexing::MAX_INDEXES_PER_COLLECTION
|
|
120
|
+
used = existing.size
|
|
121
|
+
after_no_drop = used + to_create.size
|
|
122
|
+
after_with_drop = after_no_drop - orphans.size
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
collection: collection,
|
|
126
|
+
declared: declared,
|
|
127
|
+
existing: existing,
|
|
128
|
+
parse_managed: managed.map { |i| i["name"] || i[:name] },
|
|
129
|
+
to_create: to_create,
|
|
130
|
+
in_sync: in_sync,
|
|
131
|
+
conflicts: conflicts,
|
|
132
|
+
orphans: orphans.map { |i| i["name"] || i[:name] }.compact,
|
|
133
|
+
capacity_used: used,
|
|
134
|
+
capacity_after: after_no_drop,
|
|
135
|
+
capacity_remaining: max - after_no_drop,
|
|
136
|
+
capacity_ok: after_no_drop <= max,
|
|
137
|
+
capacity_after_with_drop: after_with_drop,
|
|
138
|
+
capacity_remaining_with_drop: max - after_with_drop,
|
|
139
|
+
capacity_ok_with_drop: after_with_drop <= max,
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Apply the plan across all target collections. Additive by
|
|
144
|
+
# default; `drop: true` opts into orphan removal on every target.
|
|
145
|
+
# Each drop carries its own confirmation envelope through
|
|
146
|
+
# `Parse::MongoDB.drop_index`.
|
|
147
|
+
#
|
|
148
|
+
# @return [Hash{String => Hash}] keyed by collection name. Each
|
|
149
|
+
# value Hash mirrors the legacy single-collection apply shape:
|
|
150
|
+
# `{ created:, skipped_exists:, dropped:, conflicts:, capacity_blocked: }`.
|
|
151
|
+
def apply!(drop: false)
|
|
152
|
+
target_collections.each_with_object({}) do |coll, h|
|
|
153
|
+
h[coll] = apply_for!(coll, drop: drop)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Per-collection apply. Honors the same triple-gate / idempotency
|
|
158
|
+
# rules as the cross-collection `apply!`. When `drop: true` the
|
|
159
|
+
# method runs orphan drops BEFORE create_index so freed index
|
|
160
|
+
# slots are available to satisfy `to_create` — required when the
|
|
161
|
+
# collection is at or near the 64-index cap. Capacity is checked
|
|
162
|
+
# against the post-drop count, matching the actual mid-apply
|
|
163
|
+
# state.
|
|
164
|
+
def apply_for!(collection, drop: false)
|
|
165
|
+
p = plan_for(collection)
|
|
166
|
+
capacity_ok = drop ? p[:capacity_ok_with_drop] : p[:capacity_ok]
|
|
167
|
+
return { created: [], skipped_exists: [], dropped: [], conflicts: p[:conflicts],
|
|
168
|
+
capacity_blocked: true } unless capacity_ok
|
|
169
|
+
|
|
170
|
+
created = []
|
|
171
|
+
# Pre-seed skipped_exists with the declarations the plan already
|
|
172
|
+
# classified as in_sync — they don't go through create_index, but
|
|
173
|
+
# callers expect the result to reflect EVERY declaration's fate.
|
|
174
|
+
skipped = p[:in_sync].dup
|
|
175
|
+
dropped = []
|
|
176
|
+
|
|
177
|
+
# Drops run BEFORE creates so a full collection with one orphan
|
|
178
|
+
# and one new declaration doesn't hit "too many indexes" before
|
|
179
|
+
# the drop frees a slot.
|
|
180
|
+
if drop
|
|
181
|
+
p[:orphans].each do |name|
|
|
182
|
+
confirm = "drop:#{collection}:#{name}"
|
|
183
|
+
res = Parse::MongoDB.drop_index(collection, name, confirm: confirm,
|
|
184
|
+
allow_system_classes: collection.start_with?("_Join:"))
|
|
185
|
+
dropped << name if res == :dropped
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
p[:to_create].each do |decl|
|
|
190
|
+
result = Parse::MongoDB.create_index(
|
|
191
|
+
collection,
|
|
192
|
+
decl[:keys],
|
|
193
|
+
name: decl[:options][:name],
|
|
194
|
+
unique: decl[:options][:unique] == true,
|
|
195
|
+
sparse: decl[:options][:sparse] == true,
|
|
196
|
+
partial_filter: decl[:options][:partial_filter],
|
|
197
|
+
expire_after: decl[:options][:expire_after],
|
|
198
|
+
allow_system_classes: collection.start_with?("_Join:"),
|
|
199
|
+
)
|
|
200
|
+
(result == :exists ? skipped : created) << decl
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
{
|
|
204
|
+
created: created,
|
|
205
|
+
skipped_exists: skipped,
|
|
206
|
+
dropped: dropped,
|
|
207
|
+
conflicts: p[:conflicts],
|
|
208
|
+
capacity_blocked: false,
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# Declarations targeting `collection`. A declaration with
|
|
215
|
+
# `:collection => nil` defaults to the model's parse_class.
|
|
216
|
+
def declarations_for(collection)
|
|
217
|
+
base = collection_name
|
|
218
|
+
@model_class.mongo_index_declarations.select do |d|
|
|
219
|
+
(d[:collection] || base) == collection
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def fetch_existing_indexes(collection)
|
|
224
|
+
return [] unless defined?(Parse::MongoDB) && Parse::MongoDB.respond_to?(:enabled?) && Parse::MongoDB.enabled?
|
|
225
|
+
Parse::MongoDB.indexes(collection)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def partition_parse_managed(existing)
|
|
229
|
+
managed, ours = existing.partition do |idx|
|
|
230
|
+
name = idx["name"] || idx[:name]
|
|
231
|
+
PARSE_MANAGED_INDEX_PATTERNS.any? { |re| re.match?(name.to_s) }
|
|
232
|
+
end
|
|
233
|
+
[managed, ours]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def diff_declarations(declared, existing_ours)
|
|
237
|
+
to_create = []
|
|
238
|
+
in_sync = []
|
|
239
|
+
conflicts = []
|
|
240
|
+
|
|
241
|
+
declared.each do |decl|
|
|
242
|
+
decl_sig = key_sig(decl[:keys])
|
|
243
|
+
named = decl[:options][:name]
|
|
244
|
+
|
|
245
|
+
# Prefer a name match when the declaration named one — that's
|
|
246
|
+
# the operator's authoritative target. Otherwise match by key
|
|
247
|
+
# signature alone.
|
|
248
|
+
target = if named
|
|
249
|
+
existing_ours.find { |i| (i["name"] || i[:name]) == named }
|
|
250
|
+
end
|
|
251
|
+
target ||= existing_ours.find { |i| key_sig(i["key"] || i[:key]) == decl_sig }
|
|
252
|
+
|
|
253
|
+
if target.nil?
|
|
254
|
+
to_create << decl
|
|
255
|
+
elsif options_match?(decl, target)
|
|
256
|
+
in_sync << decl
|
|
257
|
+
else
|
|
258
|
+
conflicts << { declared: decl, existing: serialize_existing(target) }
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
[to_create, in_sync, conflicts]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def key_sig(keys)
|
|
266
|
+
return [] if keys.nil?
|
|
267
|
+
keys.map { |k, v| [k.to_s, v] }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def options_match?(decl, idx)
|
|
271
|
+
opt = decl[:options]
|
|
272
|
+
return false if (opt[:unique] == true) != (idx["unique"] == true)
|
|
273
|
+
return false if (opt[:sparse] == true) != (idx["sparse"] == true)
|
|
274
|
+
ex_partial = idx["partialFilterExpression"]
|
|
275
|
+
return false if (opt[:partial_filter] || nil) != (ex_partial || nil)
|
|
276
|
+
return false if opt[:expire_after] && idx["expireAfterSeconds"] != opt[:expire_after]
|
|
277
|
+
true
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def serialize_existing(idx)
|
|
281
|
+
{
|
|
282
|
+
name: idx["name"] || idx[:name],
|
|
283
|
+
key: idx["key"] || idx[:key],
|
|
284
|
+
unique: idx["unique"] == true,
|
|
285
|
+
sparse: idx["sparse"] == true,
|
|
286
|
+
partial_filter: idx["partialFilterExpression"],
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Schema
|
|
6
|
+
# Reconciliation engine for {Parse::Core::SearchIndexing} declarations
|
|
7
|
+
# vs. the actual Atlas Search index state. Reads existing indexes via
|
|
8
|
+
# `$listSearchIndexes` (so `plan` works without writer config);
|
|
9
|
+
# applies via the writer connection through
|
|
10
|
+
# {Parse::AtlasSearch::IndexManager.create_index} /
|
|
11
|
+
# {Parse::AtlasSearch::IndexManager.update_index} /
|
|
12
|
+
# {Parse::AtlasSearch::IndexManager.drop_index}.
|
|
13
|
+
#
|
|
14
|
+
# **Drift semantics are detect-and-refuse, not auto-update.** When a
|
|
15
|
+
# declared definition differs from what Atlas reports as the index's
|
|
16
|
+
# `latestDefinition`, the migrator classifies the declaration as
|
|
17
|
+
# `drifted:` and leaves the index alone. The operator opts into the
|
|
18
|
+
# update with `apply!(update: true)`. This matches the spirit of the
|
|
19
|
+
# regular {Parse::Schema::IndexMigrator}'s `conflicts:` slot but with
|
|
20
|
+
# an explicit opt-in escape hatch, because Atlas Search rebuilds run
|
|
21
|
+
# asynchronously and an over-eager auto-update would silently rebuild
|
|
22
|
+
# production indexes on every deploy.
|
|
23
|
+
#
|
|
24
|
+
# **Builds are async.** `apply!(wait: false)` (the default) submits
|
|
25
|
+
# commands and returns immediately. `apply!(wait: true)` blocks on
|
|
26
|
+
# {Parse::AtlasSearch::IndexManager.wait_for_ready} after each
|
|
27
|
+
# create / update to confirm the index transitions to `READY`.
|
|
28
|
+
# CI / deployment pipelines that need post-apply queryability should
|
|
29
|
+
# opt-in; default fire-and-forget keeps the common rake task fast.
|
|
30
|
+
class SearchIndexMigrator
|
|
31
|
+
attr_reader :model_class
|
|
32
|
+
|
|
33
|
+
def initialize(model_class)
|
|
34
|
+
unless model_class.is_a?(Class) && model_class < Parse::Object
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
"SearchIndexMigrator expects a Parse::Object subclass; got #{model_class.inspect}"
|
|
37
|
+
end
|
|
38
|
+
@model_class = model_class
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String] the model's collection name (parse_class).
|
|
42
|
+
def collection_name
|
|
43
|
+
@model_class.parse_class
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Compute the plan: what would change if `apply!` ran now.
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash] keys:
|
|
49
|
+
# - `:collection` — target collection name
|
|
50
|
+
# - `:declared` — Array of declaration Hashes
|
|
51
|
+
# - `:existing` — raw `$listSearchIndexes` result for the
|
|
52
|
+
# collection (or `[]` when Atlas is not available)
|
|
53
|
+
# - `:to_create` — declarations whose name is absent from
|
|
54
|
+
# `:existing`. These will be submitted on `apply!`.
|
|
55
|
+
# - `:in_sync` — declarations whose name exists AND whose
|
|
56
|
+
# normalized definition matches the existing `latestDefinition`.
|
|
57
|
+
# - `:drifted` — declarations whose name exists but whose
|
|
58
|
+
# definition differs from `latestDefinition`. Reported only;
|
|
59
|
+
# never auto-updated. Each entry is `{ declared:, existing: }`.
|
|
60
|
+
# - `:orphans` — names of search indexes present on the
|
|
61
|
+
# collection but not declared. Reported only by default;
|
|
62
|
+
# dropped under `apply!(drop: true)`.
|
|
63
|
+
# - `:atlas_available` — false when `$listSearchIndexes` failed
|
|
64
|
+
# (e.g. running against vanilla Mongo without Atlas Search).
|
|
65
|
+
# In that case `:existing` is `[]` and every declaration
|
|
66
|
+
# appears in `:to_create`.
|
|
67
|
+
def plan
|
|
68
|
+
coll = collection_name
|
|
69
|
+
existing, available = fetch_existing_indexes(coll)
|
|
70
|
+
declared = @model_class.mongo_search_index_declarations
|
|
71
|
+
|
|
72
|
+
existing_by_name = existing.each_with_object({}) do |idx, h|
|
|
73
|
+
name = (idx["name"] || idx[:name]).to_s
|
|
74
|
+
h[name] = idx unless name.empty?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
to_create = []
|
|
78
|
+
in_sync = []
|
|
79
|
+
drifted = []
|
|
80
|
+
|
|
81
|
+
declared.each do |decl|
|
|
82
|
+
target = existing_by_name[decl[:name]]
|
|
83
|
+
if target.nil?
|
|
84
|
+
to_create << decl
|
|
85
|
+
elsif definition_matches?(target, decl[:definition])
|
|
86
|
+
in_sync << decl
|
|
87
|
+
else
|
|
88
|
+
drifted << { declared: decl, existing: serialize_existing(target) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
declared_names = declared.map { |d| d[:name] }.to_set
|
|
93
|
+
orphans = existing_by_name.keys.reject { |name| declared_names.include?(name) }
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
collection: coll,
|
|
97
|
+
declared: declared,
|
|
98
|
+
existing: existing,
|
|
99
|
+
atlas_available: available,
|
|
100
|
+
to_create: to_create,
|
|
101
|
+
in_sync: in_sync,
|
|
102
|
+
drifted: drifted,
|
|
103
|
+
orphans: orphans,
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Apply the plan. Additive by default — only `:to_create` is
|
|
108
|
+
# mutated. Pass `update: true` to also rebuild drifted indexes,
|
|
109
|
+
# `drop: true` to also drop orphans, `wait: true` to block on
|
|
110
|
+
# build completion after each mutation.
|
|
111
|
+
#
|
|
112
|
+
# @param update [Boolean]
|
|
113
|
+
# @param drop [Boolean]
|
|
114
|
+
# @param wait [Boolean]
|
|
115
|
+
# @param timeout [Integer] wait-per-mutation seconds (when wait: true)
|
|
116
|
+
# @return [Hash] keys:
|
|
117
|
+
# - `:created` — Array<Hash> of declarations submitted via create
|
|
118
|
+
# - `:skipped_exists` — declarations the writer-side check found
|
|
119
|
+
# present at apply time (rare race: plan said to_create, but
|
|
120
|
+
# someone created the index in the window between plan and
|
|
121
|
+
# apply)
|
|
122
|
+
# - `:in_sync` — declarations the plan classified as in_sync
|
|
123
|
+
# (returned verbatim, no command issued)
|
|
124
|
+
# - `:updated` — names of indexes rebuilt via update
|
|
125
|
+
# (`update: true` only)
|
|
126
|
+
# - `:drifted_skipped` — names of drifted declarations that were
|
|
127
|
+
# reported but not updated (default `update: false`)
|
|
128
|
+
# - `:dropped` — names of orphans dropped (`drop: true` only)
|
|
129
|
+
# - `:orphans_skipped` — names of orphans reported but not
|
|
130
|
+
# dropped (default `drop: false`)
|
|
131
|
+
# - `:wait_results` — Hash{name => :ready|:failed|:timeout} when
|
|
132
|
+
# `wait: true`; empty otherwise.
|
|
133
|
+
def apply!(update: false, drop: false, wait: false, timeout: 600)
|
|
134
|
+
p = plan
|
|
135
|
+
coll = p[:collection]
|
|
136
|
+
wait_results = {}
|
|
137
|
+
|
|
138
|
+
# Drops run BEFORE creates so any per-cluster cap (Atlas has a
|
|
139
|
+
# cluster-wide search-index quota) doesn't reject a create that
|
|
140
|
+
# would have fit after the orphan was removed.
|
|
141
|
+
dropped = []
|
|
142
|
+
orphans_skipped = []
|
|
143
|
+
if drop
|
|
144
|
+
p[:orphans].each do |name|
|
|
145
|
+
confirm = "drop_search:#{coll}:#{name}"
|
|
146
|
+
res = Parse::AtlasSearch::IndexManager.drop_index(coll, name, confirm: confirm)
|
|
147
|
+
dropped << name if res == :dropped
|
|
148
|
+
end
|
|
149
|
+
else
|
|
150
|
+
orphans_skipped = p[:orphans].dup
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
created = []
|
|
154
|
+
skipped_exists = []
|
|
155
|
+
p[:to_create].each do |decl|
|
|
156
|
+
res = Parse::AtlasSearch::IndexManager.create_index(coll, decl[:name], decl[:definition])
|
|
157
|
+
if res == :exists
|
|
158
|
+
skipped_exists << decl
|
|
159
|
+
else
|
|
160
|
+
created << decl
|
|
161
|
+
wait_results[decl[:name]] = wait_for(coll, decl[:name], timeout) if wait
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
updated = []
|
|
166
|
+
drifted_skipped = []
|
|
167
|
+
if update
|
|
168
|
+
p[:drifted].each do |entry|
|
|
169
|
+
decl = entry[:declared]
|
|
170
|
+
Parse::AtlasSearch::IndexManager.update_index(coll, decl[:name], decl[:definition])
|
|
171
|
+
updated << decl[:name]
|
|
172
|
+
wait_results[decl[:name]] = wait_for(coll, decl[:name], timeout) if wait
|
|
173
|
+
end
|
|
174
|
+
else
|
|
175
|
+
drifted_skipped = p[:drifted].map { |e| e[:declared][:name] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
created: created,
|
|
180
|
+
skipped_exists: skipped_exists,
|
|
181
|
+
in_sync: p[:in_sync],
|
|
182
|
+
updated: updated,
|
|
183
|
+
drifted_skipped: drifted_skipped,
|
|
184
|
+
dropped: dropped,
|
|
185
|
+
orphans_skipped: orphans_skipped,
|
|
186
|
+
wait_results: wait_results,
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
# Read existing search indexes via the IndexManager's cached path.
|
|
193
|
+
# Returns `[indexes, available]`. `available` is false when Atlas
|
|
194
|
+
# isn't reachable (e.g. running against a vanilla Mongo without
|
|
195
|
+
# Search support) — the migrator degrades gracefully and treats
|
|
196
|
+
# the absence as "no indexes yet".
|
|
197
|
+
def fetch_existing_indexes(coll)
|
|
198
|
+
unless defined?(Parse::AtlasSearch::IndexManager)
|
|
199
|
+
return [[], false]
|
|
200
|
+
end
|
|
201
|
+
return [[], false] unless mongodb_enabled?
|
|
202
|
+
[Parse::AtlasSearch::IndexManager.list_indexes(coll, force_refresh: true), true]
|
|
203
|
+
rescue Parse::AtlasSearch::NotAvailable, StandardError
|
|
204
|
+
[[], false]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def mongodb_enabled?
|
|
208
|
+
defined?(Parse::MongoDB) &&
|
|
209
|
+
Parse::MongoDB.respond_to?(:enabled?) &&
|
|
210
|
+
Parse::MongoDB.enabled?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Compare a declared (normalized-already-at-DSL-time) definition
|
|
214
|
+
# against an existing index's `latestDefinition`. Both sides are
|
|
215
|
+
# deep-string-keyed before comparison so a declaration written
|
|
216
|
+
# with symbol keys compares equal to the string-keyed
|
|
217
|
+
# round-tripped value from Atlas.
|
|
218
|
+
#
|
|
219
|
+
# Returns false on any mismatch — including a missing
|
|
220
|
+
# `latestDefinition`, which Atlas may omit during a BUILDING
|
|
221
|
+
# window. A drift report in that case is the conservative answer:
|
|
222
|
+
# the operator sees the diff and decides whether to wait or
|
|
223
|
+
# re-apply.
|
|
224
|
+
def definition_matches?(existing_index, declared_definition)
|
|
225
|
+
current = existing_index["latestDefinition"] || existing_index[:latestDefinition]
|
|
226
|
+
return false unless current.is_a?(Hash)
|
|
227
|
+
normalize_for_compare(current) == normalize_for_compare(declared_definition)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Atlas normalizes submitted definitions by filling in empty
|
|
231
|
+
# default containers — e.g. `{ mappings: { dynamic: true } }` is
|
|
232
|
+
# stored as `{ mappings: { dynamic: true, fields: {} } }`. A
|
|
233
|
+
# strict deep-equal would classify every dynamic-mapping
|
|
234
|
+
# declaration as "drifted" after the first apply. Normalize by
|
|
235
|
+
# (a) stringifying keys recursively, and (b) dropping empty
|
|
236
|
+
# Hash/Array values that Atlas adds as defaults. Non-empty
|
|
237
|
+
# divergences still surface as drift.
|
|
238
|
+
def normalize_for_compare(value)
|
|
239
|
+
case value
|
|
240
|
+
when Hash
|
|
241
|
+
value.each_with_object({}) do |(k, v), h|
|
|
242
|
+
sub = normalize_for_compare(v)
|
|
243
|
+
# Drop empty Hash / Array values — Atlas adds these as
|
|
244
|
+
# defaults during normalization. A genuine `fields: {}`
|
|
245
|
+
# declaration is indistinguishable from an absent one in
|
|
246
|
+
# this scheme, but that distinction has no operational
|
|
247
|
+
# meaning either: an empty fields-map matches a missing
|
|
248
|
+
# one in Atlas's behavior.
|
|
249
|
+
next if (sub.is_a?(Hash) || sub.is_a?(Array)) && sub.empty?
|
|
250
|
+
h[k.to_s] = sub
|
|
251
|
+
end
|
|
252
|
+
when Array
|
|
253
|
+
value.map { |v| normalize_for_compare(v) }
|
|
254
|
+
else
|
|
255
|
+
value
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Retained for the `update_search_index` command path, which
|
|
260
|
+
# needs string-keyed output but not the empty-value stripping.
|
|
261
|
+
def stringify_keys_deep(value)
|
|
262
|
+
case value
|
|
263
|
+
when Hash
|
|
264
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
|
|
265
|
+
when Array
|
|
266
|
+
value.map { |v| stringify_keys_deep(v) }
|
|
267
|
+
else
|
|
268
|
+
value
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Trimmed view of an existing search index suitable for inclusion
|
|
273
|
+
# in a plan's `:drifted` entry. Drops bulky fields (full status
|
|
274
|
+
# history, statusDetail) so operator-facing output stays readable.
|
|
275
|
+
def serialize_existing(idx)
|
|
276
|
+
{
|
|
277
|
+
name: (idx["name"] || idx[:name]).to_s,
|
|
278
|
+
status: (idx["status"] || idx[:status]).to_s,
|
|
279
|
+
queryable: idx["queryable"] == true,
|
|
280
|
+
latest_definition: idx["latestDefinition"] || idx[:latestDefinition],
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def wait_for(coll, name, timeout)
|
|
285
|
+
Parse::AtlasSearch::IndexManager.wait_for_ready(coll, name, timeout: timeout)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|