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,220 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_support/concern"
5
+
6
+ module Parse
7
+ module Core
8
+ # Declarative write protection for model fields, enforced inside
9
+ # before_save webhook handling. Unlike Parse Server's class-level
10
+ # `protectedFields` (which only hides values on read), these guards
11
+ # revert disallowed client writes before the change reaches the
12
+ # persistent store.
13
+ #
14
+ # Four modes are supported:
15
+ #
16
+ # * `:master_only` - the field is never writable by clients. Any
17
+ # client-supplied value is reverted; master-key requests bypass
18
+ # the guard.
19
+ # * `:immutable` - the field is writable when the object is
20
+ # created but is reverted on any subsequent client update.
21
+ # Master-key requests bypass the guard.
22
+ # * `:always_immutable` - same as `:immutable` for creates, but the
23
+ # field is also reverted on master-key updates. Useful for fields
24
+ # that must NEVER change after creation regardless of who is
25
+ # writing (e.g. a one-way state transition marker, or a slug used
26
+ # in canonical URLs that breaks on rename).
27
+ # * `:set_once` - the field is writable while the persisted
28
+ # value is blank, then locked forever. Master-key writes DO NOT
29
+ # bypass the lock once a value is set. Useful for derived fields
30
+ # that are populated by an after_create callback (e.g.
31
+ # `parse_reference`) where the canonical value depends on the
32
+ # server-assigned objectId and must never change after first
33
+ # assignment.
34
+ #
35
+ # Reverts are a silent successful no-op from the client's perspective:
36
+ # the save proceeds normally, the guarded field simply isn't written.
37
+ # A DEBUG-level log line is emitted for diagnosis, but nothing is raised
38
+ # and nothing is logged at WARN/INFO, so clients that routinely resubmit
39
+ # a full record don't generate log noise.
40
+ #
41
+ # @example
42
+ # class Project < Parse::Object
43
+ # property :slug, :string
44
+ # property :created_by, :pointer
45
+ #
46
+ # guard :created_by, :master_only
47
+ # guard :slug, :external_id, :immutable
48
+ # end
49
+ module FieldGuards
50
+ extend ActiveSupport::Concern
51
+
52
+ GUARD_MODES = [:master_only, :immutable, :always_immutable, :set_once].freeze
53
+
54
+ included do
55
+ class_attribute :field_guards, instance_writer: false
56
+ self.field_guards = {}.freeze
57
+ end
58
+
59
+ module ClassMethods
60
+ # Declare one or more guarded fields. Two call shapes are accepted:
61
+ #
62
+ # guard :slug, :immutable # positional mode (must be the last arg)
63
+ # guard :owner, :tags, :master_only # multiple fields, positional mode
64
+ # guard :slug, mode: :immutable # keyword mode (less ambiguous)
65
+ #
66
+ # @param fields [Array<Symbol>] one or more field names
67
+ # @param mode [Symbol, nil] positional mode; required unless `mode:` keyword is given
68
+ # @param mode_kw [Symbol, nil] keyword mode (alternative to the trailing positional arg)
69
+ def guard(*fields, mode: nil)
70
+ # Support `guard :field, :master_only` by treating a trailing
71
+ # symbol that matches a known mode as the positional mode arg.
72
+ # Anything else (unknown symbol, no trailing symbol, etc.) falls
73
+ # through to the validation below with a clear error message.
74
+ if mode.nil? && fields.last.is_a?(Symbol) && GUARD_MODES.include?(fields.last)
75
+ mode = fields.pop
76
+ end
77
+
78
+ raise ArgumentError, "guard requires at least one field name" if fields.empty?
79
+ unless GUARD_MODES.include?(mode)
80
+ raise ArgumentError,
81
+ "guard mode missing or invalid: #{mode.inspect}. " \
82
+ "Allowed: #{GUARD_MODES.inspect}. Call as " \
83
+ "`guard :field, :master_only` or `guard :field, mode: :master_only`."
84
+ end
85
+
86
+ new_guards = field_guards.dup
87
+ fields.each { |f| new_guards[f.to_sym] = mode }
88
+ self.field_guards = new_guards.freeze
89
+
90
+ # Ensure Parse Server is configured to call our webhook for this
91
+ # class. Without a before_save route, the webhook is never invoked
92
+ # and the guard is silently a no-op (a credible misconfiguration
93
+ # footgun). Register a stub only if no handler exists yet; if the
94
+ # user later declares `webhook :before_save`, it replaces this stub.
95
+ ensure_field_guards_webhook!
96
+ end
97
+
98
+ # @!visibility private
99
+ def ensure_field_guards_webhook!
100
+ return unless respond_to?(:parse_class)
101
+ class_name = parse_class
102
+ return if class_name.blank?
103
+ # Load-order safety: in `lib/parse/stack.rb` the model classes are
104
+ # required before `Parse::Webhooks`, so a `guard` declaration in a
105
+ # class body (e.g. `Parse::User`) fires before the Webhooks
106
+ # constant exists. Skip the route registration in that case —
107
+ # application code that uses `guard` from its own model files (a
108
+ # later load step) will hit this path with Webhooks already
109
+ # loaded, and Parse::Webhooks.route_field_guards! re-registers the
110
+ # built-in routes after Webhooks loads.
111
+ return unless defined?(Parse::Webhooks)
112
+ existing = Parse::Webhooks.routes[:before_save][class_name]
113
+ return if existing.present?
114
+ Parse::Webhooks.route(:before_save, self) { parse_object }
115
+ end
116
+ end
117
+
118
+ # Revert any disallowed client writes per the class-level guards.
119
+ # Called by {Parse::Webhooks.call_route} for before_save triggers,
120
+ # before {Parse::Object#prepare_save!} runs.
121
+ #
122
+ # @param master [Boolean] true if the webhook request used the master key
123
+ # @param is_new [Boolean] true if this is a create (no original record)
124
+ # @return [Array<Symbol>] field names that were reverted
125
+ def apply_field_guards!(master:, is_new:)
126
+ guards = self.class.field_guards
127
+ return [] if guards.blank?
128
+
129
+ reverted = guards.each_with_object([]) do |(field, mode), acc|
130
+ next unless changed.include?(field.to_s)
131
+ case mode
132
+ when :master_only
133
+ # Master bypasses; client writes always reverted
134
+ next if master
135
+ revert_field!(field, is_new: is_new)
136
+ acc << field
137
+ when :immutable
138
+ # Master bypasses; clients can set on create, never on update
139
+ next if master
140
+ next if is_new
141
+ revert_field!(field, is_new: false)
142
+ acc << field
143
+ when :always_immutable
144
+ # No master bypass on updates: the field is frozen for everyone
145
+ # (including server/admin code using the master key) once the
146
+ # object exists. Creates are still allowed for everyone.
147
+ next if is_new
148
+ revert_field!(field, is_new: false)
149
+ acc << field
150
+ when :set_once
151
+ # Allow writes while the persisted (original) value is blank;
152
+ # lock the field once it holds a value. No master bypass --
153
+ # once set, NOTHING can change it. Implementation note: this
154
+ # checks the dirty-tracked "was" value rather than the current
155
+ # value, so an update payload that includes a new value is
156
+ # only rejected if the field was previously populated.
157
+ previous = changed_attributes[field.to_s]
158
+ next if previous.nil? || previous.to_s.strip.empty?
159
+ revert_field!(field, is_new: false)
160
+ acc << field
161
+ end
162
+ end
163
+
164
+ if reverted.any?
165
+ klass = self.class.respond_to?(:parse_class) ? self.class.parse_class : self.class.name
166
+ oid = (respond_to?(:id) && id) || "<new>"
167
+ Parse.logger&.debug(
168
+ "[Parse::FieldGuards] Reverted client writes on #{klass}:#{oid} -> #{reverted.join(", ")}"
169
+ )
170
+ end
171
+
172
+ reverted
173
+ end
174
+
175
+ private
176
+
177
+ # On update: restore the previous persisted value and clear dirty.
178
+ # On create: zero the field (still dirty) so the response payload
179
+ # tells Parse Server to drop the client-supplied value instead of
180
+ # silently letting it through.
181
+ #
182
+ # Handles three field shapes:
183
+ # * Scalar properties (including belongs_to pointers): ActiveModel
184
+ # `restore_attributes` sets back to the prior value and clears dirty.
185
+ # * has_many :relation fields: the proxy itself tracks pending
186
+ # add/remove operations; we roll those back on the proxy and clear
187
+ # the parent's dirty flag so {Parse::Object#relation_change_operations}
188
+ # emits nothing for this field.
189
+ # * has_many array fields (PointerCollectionProxy backed by an Array
190
+ # property): treated as a scalar property; `restore_attributes`
191
+ # reassigns the prior proxy. Mutations to a proxy that doesn't
192
+ # trigger a setter (e.g. some in-place edits) may not fully revert;
193
+ # prefer assigning a new array if you need strict revert semantics.
194
+ def revert_field!(field, is_new:)
195
+ field_sym = field.to_sym
196
+ field_str = field.to_s
197
+
198
+ if respond_to?(:relations) && relations[field_sym]
199
+ proxy = public_send(field_sym)
200
+ # Reset the pending add/remove ledger that backs
201
+ # relation_change_operations. The proxy itself has no public reset
202
+ # API for these (its rollback!/restore_attributes path expects
203
+ # setters that don't exist for additions/removals), so we clear
204
+ # them directly and then drop the proxy's dirty markers.
205
+ proxy.instance_variable_set(:@additions, []) if proxy.instance_variable_defined?(:@additions)
206
+ proxy.instance_variable_set(:@removals, []) if proxy.instance_variable_defined?(:@removals)
207
+ proxy.clear_changes! if proxy.respond_to?(:clear_changes!)
208
+ clear_attribute_changes([field_str])
209
+ return
210
+ end
211
+
212
+ if is_new
213
+ public_send("#{field_str}=", nil)
214
+ else
215
+ restore_attributes([field_str])
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,382 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Core
6
+ # Model-declarative MongoDB index DSL. Mixed into Parse::Object so
7
+ # subclasses can declare the indexes they expect to exist on their
8
+ # collection. Declarations are inert at load time — they only land
9
+ # on MongoDB when {Parse::Schema::IndexMigrator} reads them and
10
+ # `apply_indexes!` is invoked through the writer connection.
11
+ #
12
+ # SECURITY POSTURE — purely declarative. No network I/O, no class
13
+ # introspection that could leak data, no LLM-visible surface. The
14
+ # validation rules below run at declaration time so a typo
15
+ # surfaces as a load-time error, not a runtime surprise during
16
+ # `rake parse:mongo:indexes:apply` in prod.
17
+ #
18
+ # @example Declaring indexes
19
+ # class Car < Parse::Object
20
+ # property :make, :string
21
+ # property :model, :string
22
+ # property :year, :integer
23
+ # property :tags, :array
24
+ # property :location, :geopoint
25
+ # belongs_to :owner, as: :user
26
+ #
27
+ # mongo_index :make, :model, :year # compound
28
+ # mongo_index :vin, unique: true
29
+ # mongo_index :owner # → _p_owner (pointer auto-rewrite)
30
+ # mongo_geo_index :location # 2dsphere
31
+ # mongo_index :tags # array column
32
+ # # mongo_index :tags, :categories # REJECTED: parallel arrays
33
+ # end
34
+ module Indexing
35
+ # MongoDB limits each collection to 64 indexes total (including
36
+ # the implicit `_id_` index). The migrator's plan phase reports
37
+ # remaining capacity using this constant.
38
+ MAX_INDEXES_PER_COLLECTION = 64
39
+
40
+ # Parse-managed array columns we can know about without
41
+ # introspecting actual data. Used by {#assert_at_most_one_array_field!}
42
+ # to catch parallel-array compounds at declaration time even when
43
+ # the parallel field is the `_rperm`/`_wperm` ACL array.
44
+ PARSE_MANAGED_ARRAY_FIELDS = %w[_rperm _wperm].to_set.freeze
45
+
46
+ # Wire-format column names that hold Parse-internal secret material
47
+ # (password hashes, session tokens, verification tokens, auth provider
48
+ # blobs). The DSL refuses to declare an index on any of these because
49
+ # a queryable index on bcrypt hashes or session tokens turns
50
+ # `$indexStats` / collection-scan access into a credential-enumeration
51
+ # oracle. Parse Server already manages the legitimate indexes for
52
+ # these columns (see {Parse::Schema::IndexMigrator::PARSE_MANAGED_INDEX_PATTERNS});
53
+ # this guard exists so a typo or malicious PR can't add a new one.
54
+ SENSITIVE_FIELDS = %w[
55
+ _hashed_password _session_token _email_verify_token
56
+ _perishable_token _password_history authData _auth_data
57
+ ].freeze
58
+
59
+ # Storage for declared indexes. Each entry is a frozen Hash with
60
+ # the keys `:keys`, `:options`, `:declared_for` (the source-of-truth
61
+ # symbol list from the `mongo_index` call, for diagnostics).
62
+ # @return [Array<Hash>]
63
+ def mongo_index_declarations
64
+ @mongo_index_declarations ||= []
65
+ end
66
+
67
+ # Declare a regular (B-tree) index on one or more fields. Symbols
68
+ # in `fields` are looked up against the class's `references` table
69
+ # — pointers auto-rewrite to `_p_<field>` so callers think in
70
+ # property names. Use `mongo_geo_index` for 2dsphere indexes.
71
+ #
72
+ # @param fields [Array<Symbol>] property names; compound indexes
73
+ # are formed by passing more than one. Order matters for query
74
+ # prefix matching (MongoDB compound-index rule).
75
+ # @param unique [Boolean]
76
+ # @param sparse [Boolean]
77
+ # @param partial [Hash, nil] partial-index filter expression. Owner
78
+ # is responsible for lifecycle — Parse Server will not manage it.
79
+ # @param expire_after [Integer, nil] TTL in seconds (only valid on
80
+ # single-field indexes per MongoDB's TTL rules).
81
+ # @param name [String, nil] explicit index name; defaults to
82
+ # `field_dir_field_dir` via MongoDB's auto-naming.
83
+ # @return [Hash] the registered declaration (frozen)
84
+ # @raise [ArgumentError] when validation rules fail (no fields,
85
+ # unknown field, parallel arrays, relation field, etc.)
86
+ def mongo_index(*fields, unique: false, sparse: false, partial: nil,
87
+ expire_after: nil, name: nil)
88
+ register_index(fields, key_value: 1, unique: unique, sparse: sparse,
89
+ partial: partial, expire_after: expire_after, name: name)
90
+ end
91
+
92
+ # Sugar for a 2dsphere geospatial index. Geopoint columns are
93
+ # stored in Mongo as GeoJSON `{ type: "Point", coordinates: [lng, lat] }`
94
+ # which `2dsphere` indexes natively.
95
+ def mongo_geo_index(field, sparse: false, name: nil)
96
+ register_index([field], key_value: "2dsphere", unique: false,
97
+ sparse: sparse, partial: nil, expire_after: nil, name: name)
98
+ end
99
+
100
+ # Declare an index on a Parse Relation's join collection. Relations
101
+ # are stored in `_Join:<field>:<ParentClass>` collections — these
102
+ # have no Ruby model, so an `add_index :field` against the parent
103
+ # class would index the wrong collection. This method routes the
104
+ # declaration to the correct join-collection name, with the
105
+ # conventional column shape: `owningId` is the parent-side foreign
106
+ # key, `relatedId` is the related-side.
107
+ #
108
+ # Default: single declaration on `{owningId: 1}` — the forward
109
+ # lookup ("what's related to this owner"), which is the dominant
110
+ # pattern for most Parse Relation queries.
111
+ #
112
+ # `bidirectional: true` adds a second declaration on
113
+ # `{relatedId: 1}` — the reverse lookup ("which owners contain
114
+ # this related object"). For high-traffic auth patterns like
115
+ # `Parse::Role.users`, the reverse direction is often the
116
+ # heavier-used index.
117
+ #
118
+ # Uniqueness is NOT supported on `mongo_relation_index` — a unique
119
+ # single-direction index on a `has_many :through => :relation`
120
+ # field is semantically broken (it would say each owner can hold
121
+ # at most one related, contradicting `has_many`). If you want to
122
+ # enforce no-duplicate-pair membership, declare a compound unique
123
+ # index directly via `Parse::MongoDB.create_index` or a later
124
+ # extension to this DSL.
125
+ #
126
+ # @example Canonical case — role membership
127
+ # class Parse::Role < Parse::Object
128
+ # has_many :users, through: :relation
129
+ # mongo_relation_index :users, bidirectional: true
130
+ # # creates: _Join:users:_Role { owningId: 1 }
131
+ # # _Join:users:_Role { relatedId: 1 }
132
+ # end
133
+ #
134
+ # @param field [Symbol] the relation field name (must be declared
135
+ # via `has_many :field, through: :relation`)
136
+ # @param bidirectional [Boolean] when true, register two
137
+ # declarations — one each for owningId and relatedId
138
+ # @raise [ArgumentError] when `field` is not a declared relation
139
+ # or `unique:` is passed (not supported on relation indexes)
140
+ # @return [Array<Hash>] the registered declarations
141
+ def mongo_relation_index(field, bidirectional: false, unique: false)
142
+ if unique
143
+ raise ArgumentError,
144
+ "#{self}.mongo_relation_index does not support unique: — uniqueness on " \
145
+ "a single-direction relation column breaks has_many semantics. For no-" \
146
+ "duplicate-pair membership, declare a compound unique index directly " \
147
+ "via Parse::MongoDB.create_index."
148
+ end
149
+ field = field.to_sym
150
+ unless respond_to?(:relations) && relations.key?(field)
151
+ raise ArgumentError,
152
+ "#{self}.mongo_relation_index requires #{field.inspect} to be declared " \
153
+ "via `has_many :#{field}, through: :relation`. Got non-relation field."
154
+ end
155
+ join_collection = "_Join:#{field}:#{parse_class}"
156
+ decls = [register_relation_index(join_collection, "owningId", source: field)]
157
+ decls << register_relation_index(join_collection, "relatedId", source: field) if bidirectional
158
+ decls
159
+ end
160
+
161
+ # Dry-run reconciliation between declared indexes and what's on
162
+ # the collection. Delegates to {Parse::Schema::IndexMigrator}.
163
+ # @return [Hash{String=>Hash}] keyed by collection name; one entry
164
+ # per unique target collection across the declaration list
165
+ # (parent collection + any `_Join:*` collections from
166
+ # `mongo_relation_index`).
167
+ def indexes_plan
168
+ Parse::Schema::IndexMigrator.new(self).plan
169
+ end
170
+
171
+ # Apply additive index changes via the writer connection. Pass
172
+ # `drop: true` to also drop orphan indexes; each drop carries its
173
+ # own audit log and confirmation envelope.
174
+ # @return [Hash] see {Parse::Schema::IndexMigrator#apply!}
175
+ def apply_indexes!(drop: false)
176
+ Parse::Schema::IndexMigrator.new(self).apply!(drop: drop)
177
+ end
178
+
179
+ private
180
+
181
+ def register_index(fields, key_value:, unique:, sparse:, partial:,
182
+ expire_after:, name:)
183
+ fields = fields.flatten.map(&:to_sym)
184
+ if fields.empty?
185
+ raise ArgumentError, "#{self}.mongo_index requires at least one field name"
186
+ end
187
+ if expire_after && fields.size > 1
188
+ raise ArgumentError,
189
+ "#{self}.mongo_index expire_after is only valid on single-field indexes; " \
190
+ "got #{fields.inspect}"
191
+ end
192
+
193
+ # Sensitive-field check runs BEFORE wire-key resolution so that
194
+ # non-`_`-prefixed Parse-internal columns (e.g. `authData`) are
195
+ # caught even when they aren't declared as properties on the
196
+ # subclass — otherwise `resolve_index_field_name` would reject
197
+ # them as "unknown field" and the operator might add the property
198
+ # to silence the error, defeating the guard.
199
+ assert_no_sensitive_raw_fields!(fields)
200
+
201
+ wire_keys = fields.each_with_object({}) do |sym, h|
202
+ h[resolve_index_field_name(sym)] = key_value
203
+ end
204
+ assert_not_id_field!(wire_keys)
205
+ assert_not_sensitive_field!(wire_keys)
206
+ assert_at_most_one_array_field!(fields, wire_keys)
207
+
208
+ declaration = {
209
+ keys: wire_keys,
210
+ options: {
211
+ unique: unique, sparse: sparse,
212
+ partial_filter: partial, expire_after: expire_after, name: name,
213
+ }.reject { |_, v| v.nil? || v == false }.freeze,
214
+ declared_for: fields.dup.freeze,
215
+ collection: nil, # nil sentinel means "use the model's parse_class"
216
+ }.freeze
217
+
218
+ if mongo_index_declarations.any? { |d| d[:keys] == declaration[:keys] && d[:options] == declaration[:options] && d[:collection] == declaration[:collection] }
219
+ # Idempotent redeclaration — same class re-opened or sub-class
220
+ # inherited; don't accumulate duplicates.
221
+ return declaration
222
+ end
223
+ mongo_index_declarations << declaration
224
+ declaration
225
+ end
226
+
227
+ # Register one direction of a relation index. The declaration
228
+ # carries an explicit `:collection` override so the migrator routes
229
+ # the apply call to the `_Join:*` collection name instead of the
230
+ # model's `parse_class`.
231
+ def register_relation_index(collection, column, source:)
232
+ decl = {
233
+ keys: { column => 1 }.freeze,
234
+ options: {}.freeze,
235
+ declared_for: [source].freeze,
236
+ collection: collection,
237
+ }.freeze
238
+ if mongo_index_declarations.any? { |d| d[:keys] == decl[:keys] && d[:collection] == collection }
239
+ return decl
240
+ end
241
+ mongo_index_declarations << decl
242
+ decl
243
+ end
244
+
245
+ # Translate a property symbol to the wire-format column name a
246
+ # MongoDB index must reference. Pointer fields (declared via
247
+ # `belongs_to`) live in Mongo at `_p_<field>` and the SDK already
248
+ # tracks them in the class's `references` map. Relations
249
+ # (declared via `has_many :foo, through: :relation`) live in a
250
+ # separate `_Join:<field>:<ClassName>` collection and CAN NOT be
251
+ # indexed on the parent — reject those at declaration.
252
+ def resolve_index_field_name(sym)
253
+ sym = sym.to_sym
254
+ if respond_to?(:relations) && relations.key?(sym)
255
+ raise ArgumentError,
256
+ "#{self}.mongo_index cannot index #{sym.inspect}: it is a Parse Relation, " \
257
+ "stored in a separate _Join:#{sym}:#{self} collection. Index on the join " \
258
+ "collection directly via Parse::MongoDB.create_index if needed."
259
+ end
260
+ if respond_to?(:references) && references.key?(sym)
261
+ # Pointer field — Parse stores as _p_<field>
262
+ return "_p_#{references_field_for(sym)}"
263
+ end
264
+ # Regular property or already-wire-format string (`_rperm` etc.)
265
+ wire = if respond_to?(:field_map) && field_map[sym]
266
+ field_map[sym].to_s
267
+ else
268
+ sym.to_s
269
+ end
270
+ # Sanity check: the field should be declared, OR start with an
271
+ # underscore (internal Parse column the operator is targeting
272
+ # intentionally), OR be a valid property name.
273
+ unless internal_or_declared?(sym, wire)
274
+ raise ArgumentError,
275
+ "#{self}.mongo_index references unknown field #{sym.inspect}. " \
276
+ "Declare the property first (`property #{sym.inspect}, :string`) " \
277
+ "or pass an internal column name like :_rperm explicitly."
278
+ end
279
+ wire
280
+ end
281
+
282
+ # The `references` map stores `parse_field => target_class_name`.
283
+ # For pointer auto-rewrite we want the wire-format pointer column
284
+ # (`_p_<parseField>`), so we look up the parse field name matching
285
+ # the symbol passed to `mongo_index`.
286
+ def references_field_for(sym)
287
+ # `references` is keyed by the wire-format field. For
288
+ # `belongs_to :owner, as: :user` the entry is `owner => "_User"`,
289
+ # so the symbol matches the key directly.
290
+ if respond_to?(:field_map) && field_map[sym]
291
+ field_map[sym].to_s
292
+ else
293
+ sym.to_s
294
+ end
295
+ end
296
+
297
+ def internal_or_declared?(sym, wire)
298
+ return true if PARSE_MANAGED_ARRAY_FIELDS.include?(wire)
299
+ return true if wire.start_with?("_")
300
+ return true if respond_to?(:fields) && fields.key?(sym)
301
+ return true if respond_to?(:attributes) && attributes.key?(sym)
302
+ false
303
+ end
304
+
305
+ # MongoDB's primary key index (`_id_`, on the `_id` column) is
306
+ # auto-created and auto-maintained for every collection. Declaring
307
+ # an additional index on `_id` is at best redundant (same key as
308
+ # the primary) and at worst conflicts with the unique constraint.
309
+ # The migrator already protects `_id_` from drop via
310
+ # `PARSE_MANAGED_INDEX_PATTERNS`; this guard prevents the
311
+ # corresponding mistake on the create side at class load.
312
+ def assert_not_id_field!(wire_keys)
313
+ if wire_keys.keys.include?("_id")
314
+ raise ArgumentError,
315
+ "#{self}: cannot declare an index on `_id` — MongoDB's primary key " \
316
+ "index (`_id_`) is auto-managed and protected from modification."
317
+ end
318
+ end
319
+
320
+ # Refuse to declare an index against any Parse-internal secret column
321
+ # (see {SENSITIVE_FIELDS}). The migrator's drop-protection list
322
+ # ({Parse::Schema::IndexMigrator::PARSE_MANAGED_INDEX_PATTERNS}) only
323
+ # blocks *removal* of existing Parse-managed indexes; it does not
324
+ # prevent CREATION of a new index targeting bcrypt hashes / session
325
+ # tokens / verification tokens. Refuse those at declaration time so a
326
+ # typo (`mongo_index :_hashed_password`) or malicious change does not
327
+ # silently install a credential-enumeration oracle.
328
+ def assert_not_sensitive_field!(wire_keys)
329
+ sensitive = wire_keys.keys & SENSITIVE_FIELDS
330
+ return if sensitive.empty?
331
+ raise_sensitive_field_error!(sensitive)
332
+ end
333
+
334
+ # Pre-resolve guard for sensitive raw field symbols. Catches cases
335
+ # like `mongo_index :authData` where the field is a Parse-internal
336
+ # column but not `_`-prefixed (so `internal_or_declared?` would
337
+ # otherwise reject it as "unknown" and the operator might add a
338
+ # benign-looking `property :authData, :hash` to silence that error,
339
+ # which would then pass through to `resolve_index_field_name`
340
+ # without ever hitting the wire-key denylist).
341
+ def assert_no_sensitive_raw_fields!(fields)
342
+ names = fields.map(&:to_s)
343
+ sensitive = names & SENSITIVE_FIELDS
344
+ return if sensitive.empty?
345
+ raise_sensitive_field_error!(sensitive)
346
+ end
347
+
348
+ def raise_sensitive_field_error!(sensitive)
349
+ raise ArgumentError,
350
+ "#{self}.mongo_index cannot target sensitive Parse-internal columns: " \
351
+ "#{sensitive.inspect}. These hold password hashes, session tokens, or " \
352
+ "verification tokens; a queryable index would turn $indexStats / " \
353
+ "collection-scan access into a credential-enumeration oracle. Parse " \
354
+ "Server manages the legitimate indexes on these columns itself."
355
+ end
356
+
357
+ # MongoDB allows at most one array-typed field per compound index
358
+ # ("cannot index parallel arrays" — server error). Catch it at
359
+ # declaration time so a `mongo_index :tags, :categories`-style
360
+ # mistake fails when the class is loaded, not when the migrator
361
+ # tries to apply it.
362
+ def assert_at_most_one_array_field!(field_syms, wire_keys)
363
+ return if field_syms.size <= 1
364
+ pairs = field_syms.zip(wire_keys.keys)
365
+ arrays = pairs.select { |sym, wire| array_typed?(sym, wire) }
366
+ if arrays.size > 1
367
+ names = arrays.map { |sym, _| sym }
368
+ raise ArgumentError,
369
+ "#{self}.mongo_index cannot combine multiple array-typed fields " \
370
+ "(#{names.inspect}) in a compound index — MongoDB rejects " \
371
+ "parallel arrays. Index each array separately."
372
+ end
373
+ end
374
+
375
+ def array_typed?(sym, wire)
376
+ return true if PARSE_MANAGED_ARRAY_FIELDS.include?(wire)
377
+ return true if respond_to?(:fields) && fields[sym] == :array
378
+ false
379
+ end
380
+ end
381
+ end
382
+ end