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,407 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "active_support/concern"
5
+ require "securerandom"
6
+
7
+ module Parse
8
+ module Core
9
+ # Declarative self-referential identifier field for Parse::Object
10
+ # subclasses. When `parse_reference` is declared on a class, every newly-
11
+ # created instance gets a string field auto-populated with the canonical
12
+ # `"ClassName$objectId"` form via an `after_create` callback. The value
13
+ # mirrors Parse Server's internal pointer-column format (`_p_team` ->
14
+ # `"Team$xyz"`), which makes direct MongoDB queries, `$lookup` joins, and
15
+ # cross-class analytics trivial: a single equality match on one column.
16
+ #
17
+ # Mechanics:
18
+ #
19
+ # * The initial `save` creates the row and returns the server-assigned
20
+ # objectId. An after_create callback then sets the reference field and
21
+ # triggers a follow-up `save` — two REST round-trips per new object.
22
+ # The callback is a no-op on subsequent saves once the field matches
23
+ # the canonical value.
24
+ # * The DSL is opt-in. Classes that don't call `parse_reference` get no
25
+ # field, no callback, and no extra writes.
26
+ # * The field is logically constant once set (objectId and parse_class
27
+ # are both immutable for the object). The DSL auto-installs three
28
+ # protections:
29
+ # 1. `protect_fields("*", [field_name])` so non-master clients never
30
+ # see the column on reads.
31
+ # 2. `guard field_name, :set_once` so once the after_create populates
32
+ # the field, no further write (client or master) can change it.
33
+ # Master-key requests do NOT bypass `:set_once` once the value is
34
+ # present, so a buggy migration or admin script cannot corrupt
35
+ # the canonical reference.
36
+ # 3. A `before_save` callback (`_recompute_<field_name>!`) that
37
+ # force-recomputes the value to `"ClassName$objectId"` whenever
38
+ # the field's current value diverges from the canonical form. In
39
+ # the Parse Server `beforeSave` webhook flow this runs after
40
+ # `apply_field_guards!` and corrects any spoofed value that may
41
+ # have come from a non-gem client (other SDK, or a direct REST
42
+ # POST that includes a poisoned `parseReference` on create —
43
+ # `:set_once` allows the first write, so this callback is the
44
+ # belt to that suspenders).
45
+ # * Inherits cleanly into `Parse::User`, `Parse::Installation`, and
46
+ # other system-class subclasses. The reference format becomes
47
+ # `"_User$objectId"`, `"_Installation$objectId"`, etc., matching
48
+ # Parse Server's own `_p_user`/`_p_installation` column format.
49
+ # * Batch / transaction caveat: `Parse::Object.transaction` and
50
+ # `Parse::Object.save_all` set the server-assigned objectId via
51
+ # `instance_variable_set` without running the `:create` callback
52
+ # chain. Objects created through those paths therefore do NOT have
53
+ # the parse_reference auto-populated. Use the
54
+ # {ClassMethods#populate_parse_references!} batch helper or call
55
+ # `obj._assign_<field>!` manually after the transaction commits.
56
+ #
57
+ # == `precompute: true` — server requirements and threat model
58
+ #
59
+ # The `precompute: true` option client-generates the objectId in a
60
+ # `before_create` callback and embeds both `objectId` and the canonical
61
+ # reference in the initial POST body, eliminating the follow-up
62
+ # `update!` that the default after_create flow issues. Two requirements
63
+ # must hold for this to work end-to-end:
64
+ #
65
+ # 1. Parse Server must be started with `allowCustomObjectId: true`
66
+ # (`PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID=true`). Without that flag,
67
+ # Parse Server rejects any create whose body contains `objectId`
68
+ # with `error: objectId is an invalid field name` (HTTP 400, code
69
+ # 105) before any cloud-code hooks run.
70
+ # 2. The save must run with master-key authority. The DSL enforces
71
+ # this SDK-side: `_precompute_<field>!` is a no-op when the
72
+ # instance has a per-save session token set (`with_session` /
73
+ # `set_session_token`) or when no `master_key` is configured on
74
+ # `Parse::Client`. In either case the legacy after_create
75
+ # `_assign_<field>!` flow takes over, costing one extra round-trip
76
+ # but staying within the session's permissions. The local @id
77
+ # falls back to the server-assigned id (no client id is generated
78
+ # or forwarded), so the resulting `parseReference` is correct.
79
+ #
80
+ # The SDK gate protects parse-stack callers, but `allowCustomObjectId`
81
+ # is a server-global flag — it also lets the JS SDK, iOS SDK, raw
82
+ # REST callers, and any other client using the same Parse Server pick
83
+ # their own `objectId` on create. That permits objectId-squatting
84
+ # ("admin", "root", colliding with another tenant's id), id-spoofing
85
+ # on classes whose ACL allows public create, and a few subtle CLP
86
+ # bypass shapes when a class's class-level permissions key off
87
+ # `objectId` patterns. To enforce master-only client objectIds across
88
+ # ALL SDKs, register a Cloud Code `beforeSave` hook that rejects
89
+ # client-supplied ids from non-master sessions, e.g.:
90
+ #
91
+ # Parse.Cloud.beforeSave("MyClass", req => {
92
+ # if (req.original === undefined && req.object.id && !req.master) {
93
+ # throw "Client-supplied objectId not allowed";
94
+ # }
95
+ # });
96
+ #
97
+ # `req.original === undefined` narrows to creates (no prior state);
98
+ # `req.object.id` is the client-supplied id; `!req.master` excludes
99
+ # legitimate master-key creates including this gem's precompute path.
100
+ # Apply per-class for the classes that declare
101
+ # `parse_reference precompute: true`, or globally on every class via
102
+ # `Parse.Cloud.beforeSave(Parse.Object, ...)` if the application has
103
+ # no legitimate non-master custom-id use case.
104
+ #
105
+ # @example default field name
106
+ # class Post < Parse::Object
107
+ # parse_reference # local :parse_reference -> remote "parseReference"
108
+ # end
109
+ # post = Post.create(title: "Hi")
110
+ # post.parse_reference # => "Post$abc123"
111
+ #
112
+ # @example custom local name
113
+ # class Event < Parse::Object
114
+ # parse_reference :ref
115
+ # end
116
+ #
117
+ # @example custom local AND remote names
118
+ # class Activity < Parse::Object
119
+ # parse_reference :ref, field: "refKey"
120
+ # end
121
+ #
122
+ # @example works on system class subclasses (for normal Parse::Object
123
+ # creates -- NOT for Parse::User#signup!, which goes through a
124
+ # distinct REST endpoint and does not run the `:create` callback
125
+ # chain. On a User subclass, populate the reference manually after
126
+ # signup: `user._assign_parse_reference!`.)
127
+ # class User < Parse::User
128
+ # parse_reference
129
+ # end
130
+ module ParseReference
131
+ extend ActiveSupport::Concern
132
+
133
+ # The separator between class name and object id. Matches Parse Server's
134
+ # own pointer-column format (e.g. `_p_team = "Team$abcd1234"`).
135
+ SEPARATOR = "$".freeze
136
+
137
+ # Length of a Parse Server objectId. Matches the format the server itself
138
+ # produces and what the JS/iOS SDKs generate for offline-mode local ids.
139
+ OBJECT_ID_LENGTH = 10
140
+
141
+ # Generate a Parse-compatible objectId: 10 characters drawn from
142
+ # [A-Za-z0-9]. Used by the precompute path so a `before_create` callback
143
+ # can assign `@id` (and the canonical reference string) before the
144
+ # initial POST, eliminating the second round-trip that the default
145
+ # after_create approach requires.
146
+ #
147
+ # 62^10 ≈ 8.39e17 keyspace; collision probability is negligible at any
148
+ # practical scale. Parse Server accepts client-assigned `objectId` in
149
+ # POST bodies (the JS/iOS SDKs use this for offline mode) and rejects
150
+ # duplicates with a specific error code rather than silently overwriting.
151
+ def self.generate_object_id
152
+ SecureRandom.alphanumeric(OBJECT_ID_LENGTH)
153
+ end
154
+
155
+ # Build a canonical "Class$id" reference string. Returns nil if either
156
+ # piece is blank — callers wiring this into other systems can use the
157
+ # nil to skip writing the field.
158
+ def self.format(parse_class, id)
159
+ return nil if parse_class.to_s.empty? || id.to_s.empty?
160
+ "#{parse_class}#{SEPARATOR}#{id}"
161
+ end
162
+
163
+ # Split a "Class$id" string into [class_name, object_id]. Returns
164
+ # [nil, nil] for nil input; raises ArgumentError on malformed input
165
+ # (anything else than a string containing the separator).
166
+ def self.parse(string)
167
+ return [nil, nil] if string.nil?
168
+ unless string.is_a?(String) && string.include?(SEPARATOR)
169
+ raise ArgumentError, "not a parse_reference: #{string.inspect}"
170
+ end
171
+ string.split(SEPARATOR, 2)
172
+ end
173
+
174
+ module ClassMethods
175
+ # Declare a self-referential identifier field on this class.
176
+ # See {Parse::Core::ParseReference} for full documentation.
177
+ #
178
+ # @param field_name [Symbol] local property name (default :parse_reference)
179
+ # @param field [String, nil] remote Parse column name; defaults to the
180
+ # camelCased form of `field_name`
181
+ # @param precompute [Boolean] when true, generate the objectId
182
+ # client-side in a `before_create` callback and embed the canonical
183
+ # reference in the initial POST body, eliminating the second
184
+ # round-trip. When false (default) the value is set via an
185
+ # `after_create` callback that issues a follow-up `update!`.
186
+ # @return [Symbol] the registered field name
187
+ def parse_reference(field_name = :parse_reference, field: nil, precompute: false,
188
+ index: true, unique_index: true)
189
+ field_name = field_name.to_sym
190
+ unless field_name.to_s =~ /\A[a-z_][a-z0-9_]*\z/i
191
+ raise ArgumentError,
192
+ "parse_reference field name must match /\\A[a-z_][a-z0-9_]*\\z/i, got #{field_name.inspect}"
193
+ end
194
+ remote = field || field_name.to_s.camelize(:lower)
195
+ property field_name, :string, field: remote
196
+
197
+ # Auto-register a MongoDB index declaration for this field. The
198
+ # synchronize_create correctness floor (CHANGELOG 4.4.0) relies on
199
+ # a unique index on the dedup tuple — auto-registering removes
200
+ # the operator-must-remember failure mode. The index is unique
201
+ # AND sparse by default: sparse so that
202
+ # `Parse.populate_parse_references!` backfill can walk rows with
203
+ # NULL values without tripping the unique constraint on the
204
+ # second NULL. Operators can opt out per-field:
205
+ # - `index: false` — skip registration entirely
206
+ # - `unique_index: false` — register the index but drop the
207
+ # unique constraint (cheaper lookups without the dedup guarantee)
208
+ # The declaration is inert at load time; it ships through the
209
+ # standard `Parse::Schema::IndexMigrator` plan/apply path so the
210
+ # writer URI + triple gate still gates actual mutation.
211
+ if index && respond_to?(:mongo_index)
212
+ opts = { sparse: true }
213
+ opts[:unique] = true if unique_index
214
+ mongo_index field_name, **opts
215
+ end
216
+
217
+ # Auto-install read-side hiding: clients shouldn't see the
218
+ # internal reference column. Master/admin reads (which is how
219
+ # analytics queries and direct Mongo lookups run) are unaffected
220
+ # because protect_fields("*", ...) only applies to non-master
221
+ # reads. Merge into any existing "*" protected fields rather
222
+ # than overwriting (the underlying set_protected_fields method
223
+ # replaces by pattern).
224
+ if respond_to?(:protect_fields) && respond_to?(:class_permissions)
225
+ existing = class_permissions.protected_fields_for("*") rescue []
226
+ merged = (existing + [field_name.to_s]).uniq
227
+ protect_fields("*", merged)
228
+ end
229
+
230
+ # Auto-install write-side protection: once the after_create
231
+ # populates the value, nothing (including master) can rewrite
232
+ # it. :set_once allows the first transition from blank to a
233
+ # value, then locks the field forever.
234
+ if respond_to?(:guard)
235
+ guard field_name, :set_once
236
+ end
237
+
238
+ # Define a helper that computes the canonical value and writes
239
+ # via `update!` (bypassing the user's save/create callback
240
+ # chain so this internal bookkeeping write doesn't double-fire
241
+ # after_save hooks the user has on the class).
242
+ method_name = :"_assign_#{field_name}!"
243
+ define_method(method_name) do
244
+ return unless id.present?
245
+ target = Parse::Core::ParseReference.format(self.class.parse_class, id)
246
+ return if public_send(field_name) == target
247
+ public_send("#{field_name}=", target)
248
+ ok = update!
249
+ unless ok
250
+ Parse.logger&.warn(
251
+ "[Parse::ParseReference] Failed to persist #{self.class.parse_class}##{id} " \
252
+ "#{field_name} = #{target.inspect}; object exists without its reference field. " \
253
+ "errors=#{errors.full_messages.inspect rescue nil}"
254
+ )
255
+ end
256
+ ok
257
+ end
258
+
259
+ # Expose the configured field name as a class-level reader so
260
+ # the batch-populate helper and other introspection code can
261
+ # find it without re-parsing the class body.
262
+ @_parse_reference_fields ||= []
263
+ @_parse_reference_fields << field_name
264
+ singleton_class.send(:attr_reader, :_parse_reference_fields) unless singleton_class.method_defined?(:_parse_reference_fields)
265
+
266
+ # Register the after_create callback, but only if this exact
267
+ # method isn't already in the callback chain. Re-declaration in a
268
+ # subclass (or accidental double-declaration in the same class)
269
+ # otherwise stacks multiple invocations and produces multiple
270
+ # extra REST writes per create. The check inspects the chain by
271
+ # filter name so it correctly handles both fresh registration
272
+ # and inheritance from a parent that already declared.
273
+ already_registered = _create_callbacks.any? do |cb|
274
+ (cb.filter.to_sym rescue cb.filter) == method_name
275
+ end
276
+ after_create method_name unless already_registered
277
+
278
+ # Belt-and-suspenders: on every save where the field's current
279
+ # value diverges from the canonical "ClassName$objectId" form,
280
+ # force-recompute it. This callback runs in two contexts:
281
+ #
282
+ # 1. Gem-side save flow — fires before `before_create`, so on a
283
+ # fresh object (id blank) it's a no-op; on a subsequent
284
+ # `_assign_<field>!`-triggered `update!` the value already
285
+ # matches so it's also a no-op.
286
+ # 2. Parse Server `beforeSave` webhook flow — Parse::Webhooks
287
+ # deserializes the incoming object, runs `apply_field_guards!`
288
+ # (which reverts disallowed client writes per the `:set_once`
289
+ # guard above), then invokes `prepare_save!` which fires this
290
+ # `:save` callback chain. The object's id has been assigned by
291
+ # Parse Server at this point. If any value slipped past the
292
+ # guard (master-key write, or first-write on create), this
293
+ # callback overwrites it with the canonical value. The
294
+ # enforcement happens server-side regardless of which SDK
295
+ # originated the save.
296
+ recompute_method = :"_recompute_#{field_name}!"
297
+ define_method(recompute_method) do
298
+ return unless id.present?
299
+ target = Parse::Core::ParseReference.format(self.class.parse_class, id)
300
+ return if target.nil?
301
+ return if public_send(field_name) == target
302
+ public_send("#{field_name}=", target)
303
+ end
304
+
305
+ already_recomputing = _save_callbacks.any? do |cb|
306
+ cb.kind == :before && (cb.filter.to_sym rescue cb.filter) == recompute_method
307
+ end
308
+ before_save recompute_method unless already_recomputing
309
+
310
+ if precompute
311
+ precompute_method = :"_precompute_#{field_name}!"
312
+ define_method(precompute_method) do
313
+ # Precompute is master-key-only. Parse Server rejects a
314
+ # client-supplied `objectId` in the create body unless its
315
+ # `allowCustomObjectId` option is enabled, and even with that
316
+ # global flag on, accepting client-set objectIds from
317
+ # non-master sessions is an objectId-squatting risk
318
+ # (attacker picks "admin", "root", or collides with another
319
+ # tenant's id). Skip precompute when this save won't run as
320
+ # master: an explicit per-save session token is present
321
+ # (`with_session` / `set_session_token`), or no master key is
322
+ # configured on the client at all. In those cases the legacy
323
+ # after_create `_assign_<field>!` flow takes over, costing
324
+ # one extra round-trip but staying within whatever
325
+ # permissions the requesting session has.
326
+ return if _session_token.present?
327
+ return unless client.respond_to?(:master_key) && client.master_key.present?
328
+
329
+ if id.blank?
330
+ @id = Parse::Core::ParseReference.generate_object_id
331
+ end
332
+ target = Parse::Core::ParseReference.format(self.class.parse_class, id)
333
+ # We just client-assigned @id, so the instance now satisfies
334
+ # `pointer?` (objectId present, timestamps blank). The property
335
+ # accessor's autofetch heuristic — and the setter's
336
+ # prepare_for_dirty_tracking! pre-fetch — would both fire a GET
337
+ # against an id Parse Server has not seen yet, producing a 101
338
+ # Object not found and aborting the create. Suppress autofetch
339
+ # for the duration of this callback's writes; the actual create
340
+ # POST that follows includes both objectId and parse_reference,
341
+ # so server state is unaffected.
342
+ was_disabled = autofetch_disabled?
343
+ disable_autofetch!
344
+ begin
345
+ return if public_send(field_name) == target
346
+ public_send("#{field_name}=", target)
347
+ ensure
348
+ enable_autofetch! unless was_disabled
349
+ end
350
+ end
351
+
352
+ already_precomputing = _create_callbacks.any? do |cb|
353
+ (cb.filter.to_sym rescue cb.filter) == precompute_method
354
+ end
355
+ # before_create runs inside Parse::Object#create, AFTER the
356
+ # save dispatcher has already chosen the create-vs-update path
357
+ # (actions.rb:795). Setting @id here therefore cannot reroute
358
+ # the save. `new?` remains correct because it also checks
359
+ # @created_at, which is still nil at this point.
360
+ before_create precompute_method unless already_precomputing
361
+ end
362
+
363
+ field_name
364
+ end
365
+
366
+ # Populate the parse_reference field for an array of already-saved
367
+ # objects. Use after `Parse::Object.transaction` or `save_all`
368
+ # (both of which bypass the `:create` callback chain) so the
369
+ # canonical reference still lands in MongoDB. Each object gets an
370
+ # individual `update!` call -- callers wanting tighter batching
371
+ # can wrap multiple updates in their own `Parse::Object.transaction`.
372
+ #
373
+ # Objects that already have a populated reference, or that lack an
374
+ # objectId, are skipped silently.
375
+ #
376
+ # @example
377
+ # posts = []
378
+ # Post.transaction do |batch|
379
+ # 3.times { posts << Post.new(title: "hi").tap { |p| batch.add(p) } }
380
+ # end
381
+ # Post.populate_parse_references!(posts) # second round-trip per object
382
+ #
383
+ # @param objects [Array<Parse::Object>] objects to populate
384
+ # @return [Array<Parse::Object>] the objects that were updated
385
+ def populate_parse_references!(objects)
386
+ return [] if objects.nil? || objects.empty?
387
+ fields_to_populate = Array(@_parse_reference_fields)
388
+ return [] if fields_to_populate.empty?
389
+ updated = []
390
+ objects.each do |obj|
391
+ next unless obj.is_a?(self) && obj.id.present?
392
+ changed_any = false
393
+ fields_to_populate.each do |field_name|
394
+ method = :"_assign_#{field_name}!"
395
+ next unless obj.respond_to?(method)
396
+ before = obj.public_send(field_name)
397
+ obj.public_send(method)
398
+ changed_any ||= (obj.public_send(field_name) != before)
399
+ end
400
+ updated << obj if changed_any
401
+ end
402
+ updated
403
+ end
404
+ end
405
+ end
406
+ end
407
+ end