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,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