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,1348 @@
1
+ # Direct MongoDB Integration Guide
2
+
3
+ Parse Stack can talk to MongoDB directly, bypassing Parse Server's REST
4
+ layer for read-heavy workloads. This guide covers the configuration, the
5
+ read paths, the storage-format details you need to know to write working
6
+ aggregation pipelines, and the `parse_reference` optimization that makes
7
+ multi-collection `$lookup` joins fast.
8
+
9
+ Direct MongoDB is **read-only**. Writes must go through Parse Server so
10
+ beforeSave/afterSave hooks, ACLs, and schema validation still apply.
11
+
12
+ **v4.4.0:** the direct read path now applies row-level ACL, Class-Level
13
+ Permissions, and `protectedFields` enforcement SDK-side when the caller
14
+ declares a scope (`session_token:`, `acl_user:`, or `acl_role:`). See
15
+ [Security](#security) for the full enforcement contract. Master-key
16
+ calls and unscoped calls still bypass these layers — they're the
17
+ explicit opt-out for analytics and admin workloads.
18
+
19
+ ---
20
+
21
+ ## When to use it
22
+
23
+ The direct path is the right tool when one or more of the following is
24
+ true:
25
+
26
+ - The query reads a high-cardinality result set and the REST round-trip
27
+ dominates latency.
28
+ - You need an aggregation pipeline (`$group`, `$bucket`, `$facet`,
29
+ `$lookup`) that Parse Server's `/aggregate` endpoint doesn't accept or
30
+ doesn't pass through cleanly. **Note:** Parse Server's REST aggregate
31
+ requires master key AND enforces neither ACL nor CLP, so any
32
+ user-context aggregation should run through the direct path with a
33
+ scope (where the SDK enforces) rather than through REST aggregate
34
+ (where nothing does).
35
+ - You need analytics-style joins across Parse classes, which the REST
36
+ layer can't perform efficiently.
37
+ - You want to drive the query against a MongoDB secondary replica
38
+ (read preference is configured at the driver, not at Parse Server).
39
+ - You need ACL/CLP-enforced aggregation against a user-context scope
40
+ (v4.4.0).
41
+
42
+ The direct path is the **wrong** tool when:
43
+
44
+ - You're writing data — direct writes bypass every beforeSave/afterSave
45
+ hook and ACL/CLP check in Parse Server. Always write through Parse
46
+ Server.
47
+ - The deployment is a Parse Server-only environment where the gem doesn't
48
+ have direct MongoDB access (most production Parse Server hosts).
49
+ - You need ACL/CLP enforcement and you call without supplying a scope
50
+ kwarg. An unscoped direct call runs in master-key posture and skips
51
+ the SDK enforcement layers. Either declare a scope or use REST
52
+ find/get/count (where Parse Server applies the enforcement itself).
53
+
54
+ ---
55
+
56
+ ## Configuration
57
+
58
+ ### Gemfile
59
+
60
+ ```ruby
61
+ gem "mongo", "~> 2.18"
62
+ ```
63
+
64
+ The `mongo` driver gem is **not** a hard dependency of Parse Stack. The
65
+ direct path raises `Parse::MongoDB::GemNotAvailable` if you try to use it
66
+ without the gem present.
67
+
68
+ ### Connection
69
+
70
+ ```ruby
71
+ require "parse/mongodb"
72
+
73
+ Parse::MongoDB.configure(
74
+ uri: "mongodb://user:pass@host:27017/parse?authSource=admin",
75
+ enabled: true,
76
+ )
77
+ ```
78
+
79
+ The database name is extracted from the URI path (`/parse` in the
80
+ example); override it explicitly with `database: "name"` if your URI
81
+ doesn't include one.
82
+
83
+ ### Env-var resolution (recommended)
84
+
85
+ When `uri:` is omitted, `configure` resolves the URI from environment
86
+ variables in priority order:
87
+
88
+ 1. `ANALYTICS_DATABASE_URI` — dedicated direct-read endpoint, typically
89
+ pointed at an analytics replica.
90
+ 2. `DATABASE_URI` — Parse Server's primary connection. Fallback for
91
+ deployments where direct reads share the primary cluster.
92
+
93
+ ```ruby
94
+ # In deployment config:
95
+ # ANALYTICS_DATABASE_URI=mongodb+srv://analytics_ro:...@cluster.mongodb.net/parse?...
96
+ # DATABASE_URI=mongodb+srv://parse_rw:...@cluster.mongodb.net/parse
97
+
98
+ # In code -- no URI hard-coded:
99
+ Parse::MongoDB.configure(enabled: true)
100
+ ```
101
+
102
+ `ANALYTICS_DATABASE_URI` taking priority lets operators point direct
103
+ traffic at a dedicated analytics endpoint without touching Parse Server's
104
+ primary `DATABASE_URI`. Configure both: Parse Server keeps writing
105
+ against `DATABASE_URI`; the gem's direct-read path reads from
106
+ `ANALYTICS_DATABASE_URI`.
107
+
108
+ `configure` raises `ArgumentError` if neither a `uri:` argument nor any
109
+ of the env vars is set.
110
+
111
+ ### Feature flag
112
+
113
+ `Parse::MongoDB.enabled?` returns `true` only after `configure` has run
114
+ and `enabled: true` was set. Use the flag to gate code paths so
115
+ deployments without direct access fall back gracefully:
116
+
117
+ ```ruby
118
+ if Parse::MongoDB.available?
119
+ songs = Song.query(genre: "Jazz").results_direct
120
+ else
121
+ songs = Song.query(genre: "Jazz").results
122
+ end
123
+ ```
124
+
125
+ `available?` also checks that the `mongo` gem is loaded; prefer it over
126
+ `enabled?` for control-flow.
127
+
128
+ ---
129
+
130
+ ## Read paths
131
+
132
+ There are four entry points, listed from highest level to lowest.
133
+
134
+ ### `Query#results_direct`
135
+
136
+ ```ruby
137
+ songs = Song.query(:plays.gt => 1000)
138
+ .order(:plays.desc)
139
+ .limit(50)
140
+ .results_direct
141
+ ```
142
+
143
+ Compiles the query's `where`/`order`/`limit`/`skip` into an aggregation
144
+ pipeline, runs it through MongoDB directly, and decodes the documents
145
+ into `Parse::Object` instances. Returns a `Parse::Object` array.
146
+
147
+ Options:
148
+
149
+ - `raw: true` — skip decoding and return Parse-formatted JSON hashes.
150
+ - `max_time_ms: 5000` — cancel the query if MongoDB exceeds the budget
151
+ (raises `Parse::MongoDB::ExecutionTimeout`).
152
+ - `session_token: <bearer>`, `acl_user: <Parse::User>`, `acl_role: <name>`,
153
+ or `master: true` (v4.4.0) — declare the authorization scope. The
154
+ SDK applies ACL + CLP + `protectedFields` enforcement when a scope
155
+ is supplied; see [Security](#security) for the full enforcement
156
+ contract. Omitting all four falls through to public-only semantics
157
+ (with a one-time `[Parse::ACLScope:SECURITY]` banner).
158
+
159
+ ```ruby
160
+ # User-context read: SDK enforces ACL + CLP for the user's claim set
161
+ Song.query.results_direct(session_token: current_user.session_token)
162
+
163
+ # Pre-resolved User pointer: skips /users/me, same enforcement
164
+ Song.query.results_direct(acl_user: current_user)
165
+
166
+ # Service-account / analytics: explicit master-mode opt-out
167
+ Song.query(:plays.gt => 1000).results_direct(master: true)
168
+ ```
169
+
170
+ The convenience helpers `scope_to_user(user)` / `scope_to_role(role)`
171
+ set the same kwargs on the query for chainable composition.
172
+
173
+ Related: `first_direct(n)` for the first N rows, `count_direct` for a
174
+ count-only query. Both accept the same auth kwargs.
175
+
176
+ ### `Query#aggregate(pipeline, mongo_direct: true)`
177
+
178
+ ```ruby
179
+ pipeline = [
180
+ { "$group" => { "_id" => "$genre", "total_plays" => { "$sum" => "$plays" } } },
181
+ { "$sort" => { "total_plays" => -1 } },
182
+ ]
183
+ agg = Song.query.aggregate(pipeline, mongo_direct: true)
184
+ agg.results
185
+ ```
186
+
187
+ `Query#aggregate` prepends the query's `where`/`order`/`limit`/`skip` as
188
+ pipeline stages, then appends `pipeline`, then dispatches via direct
189
+ MongoDB. The return is a `Parse::Query::Aggregation` instance with
190
+ `.results`, `.raw`, and `.result_pointers` accessors.
191
+
192
+ `mongo_direct: nil` (the default) lets Parse Stack decide; it auto-flips
193
+ to `true` when the constraint compiler produced `$inQuery`/`$notInQuery`
194
+ `$lookup` stages (which Parse Server's REST aggregate endpoint can't
195
+ execute) and `Parse::MongoDB.enabled?` is true.
196
+
197
+ ### `Parse::MongoDB.aggregate(class_name, pipeline)`
198
+
199
+ ```ruby
200
+ pipeline = [{ "$match" => { "releaseDate" => { "$gte" => Time.utc(2024, 1, 1) } } }]
201
+ raw_docs = Parse::MongoDB.aggregate("Song", pipeline, max_time_ms: 3000)
202
+ ```
203
+
204
+ Raw entry point. Accepts the class name (which is also the MongoDB
205
+ collection name — `User` is `_User`, etc.) and an aggregation pipeline.
206
+ Returns raw MongoDB documents — no Parse-format conversion is applied.
207
+ Use `Parse::MongoDB.convert_documents_to_parse(raw_docs, "Song")` to
208
+ convert into the Parse JSON shape, then `Song.new(doc)` or
209
+ `Song.find(doc["objectId"])` to hydrate objects.
210
+
211
+ Authorization kwargs (v4.4.0) — pass at most ONE:
212
+
213
+ - `session_token: <bearer>` — round-trips Parse Server's `/users/me`
214
+ to resolve the user and expand role membership.
215
+ - `acl_user: <Parse::User or Pointer>` — pre-resolved identity, skips
216
+ the token round-trip. Role expansion runs via `Parse::Role.all_for_user`.
217
+ - `acl_role: <Parse::Role or name>` — service-account scope; no user_id,
218
+ just the role + transitively inherited roles.
219
+ - `master: true` — explicit ACL/CLP opt-out (analytics, admin).
220
+
221
+ The full enforcement contract (ACL row-level + CLP + `protectedFields`)
222
+ runs when any of the first three is supplied. See [Security](#security)
223
+ for what each layer does and why master-mode bypasses everything.
224
+
225
+ ### `Parse::MongoDB.find(class_name, filter, **options)`
226
+
227
+ ```ruby
228
+ raw = Parse::MongoDB.find(
229
+ "Song",
230
+ { "genre" => "Rock" },
231
+ limit: 100, sort: { "plays" => -1 },
232
+ )
233
+ ```
234
+
235
+ Convenience wrapper around `db.find`. Accepts `limit:`, `skip:`, `sort:`,
236
+ `projection:`, `max_time_ms:`. When `:limit` is omitted the call applies
237
+ `DEFAULT_FIND_LIMIT = 1000` and warns; pass `limit: 0` to opt out.
238
+
239
+ ### Geo queries
240
+
241
+ Three geo query constraints land in v4.4.0 alongside a direct
242
+ `Parse::MongoDB.geo_near` aggregation helper. All four operate on
243
+ MongoDB GeoJSON geometries via `2dsphere` indexes, so the queried
244
+ column must be indexed (`mongo_geo_index :location` on the model, or
245
+ manual `db.collection.createIndex({location: "2dsphere"})`).
246
+
247
+ | Constraint | Generates | Routing |
248
+ | ----------------------------- | ---------------------- | ---------------------------------------- |
249
+ | `:field.near_sphere => point` | `$nearSphere` | REST or mongo-direct (Parse Server supports both). |
250
+ | `:field.within_sphere => [point, radius, :miles]` | `$geoWithin: $centerSphere` | **Mongo-direct only** — `$centerSphere` is not a Parse Server REST operator. The constraint emits `__mongo_direct_only` and the query auto-routes to `results_direct`. |
251
+ | `:field.geo_intersects => geometry` | `$geoIntersects: $geometry` | Mongo-direct only. |
252
+ | `:field.polygon_contains => point` | `$geoIntersects: $point` | REST or mongo-direct. |
253
+ | `:field.within_polygon => polygon` | `$geoWithin: $polygon` | REST when value is a GeoPoint array; mongo-direct when value is `Parse::Polygon`. |
254
+
255
+ ```ruby
256
+ class Place < Parse::Object
257
+ property :location, :geopoint
258
+ mongo_geo_index :location
259
+ end
260
+
261
+ near_me = Place.query(:location.near_sphere => Parse::GeoPoint.new(37.7749, -122.4194))
262
+ within_5mi = Place.query(:location.within_sphere => [Parse::GeoPoint.new(37.7749, -122.4194), 5, :miles])
263
+ in_bbox = Place.query(:location.geo_intersects => bbox_geojson)
264
+ ```
265
+
266
+ For constraints that auto-route to mongo-direct (`within_sphere`,
267
+ `geo_intersects`, and `within_polygon` with a `Parse::Polygon`), the
268
+ caller's auth scope must reach the mongo-direct path the same way it
269
+ does for any other mongo-direct query — `master:`, `session_token:`,
270
+ `acl_user:`, or `acl_role:` resolution applies.
271
+
272
+ #### `Parse::MongoDB.geo_near(class_name, near:, **options)`
273
+
274
+ ```ruby
275
+ results = Parse::MongoDB.geo_near(
276
+ "Place",
277
+ near: Parse::GeoPoint.new(37.7749, -122.4194),
278
+ distance_field: "distance_meters",
279
+ max_distance: 5_000, # meters
280
+ spherical: true, # default
281
+ query: { "category" => "cafe" }, # additional $match
282
+ limit: 50,
283
+ session_token: request_session, # or master:/acl_user:/acl_role:
284
+ )
285
+ ```
286
+
287
+ Builds and runs a `$geoNear` aggregation stage. `$geoNear` must be the
288
+ first stage of any pipeline that uses it, so this helper handles stage
289
+ ordering for you. Returns each document with the configured
290
+ `distanceField` populated.
291
+
292
+ **Coordinate-order convention**: `near:` accepts `Parse::GeoPoint` or
293
+ a GeoJSON `{type:"Point", coordinates:[lng,lat]}` Hash. Prefer
294
+ `Parse::GeoPoint` to avoid axis-order mistakes — GeoJSON uses
295
+ `[lng,lat]` while the rest of the Parse SDK uses `[lat,lng]`.
296
+
297
+ #### Winding order (MongoDB 8+ / Atlas)
298
+
299
+ MongoDB 8+ and recent Atlas releases enforce RFC 7946 for polygons
300
+ used in `$geoWithin` / `$geoIntersects` against `2dsphere` indexes:
301
+ the outer ring must be wound counter-clockwise. `Parse::Polygon` ships
302
+ with `counter_clockwise?` and `ensure_counter_clockwise!` helpers, and
303
+ `Parse::Polygon#_validate` emits a warning when an outer ring is
304
+ clockwise. `to_geojson` does not auto-correct — call
305
+ `polygon.ensure_counter_clockwise!` before persisting or querying if
306
+ you can't guarantee the input.
307
+
308
+ ---
309
+
310
+ ## Storage-format reference
311
+
312
+ Parse Server stores documents in MongoDB with a specific shape. To write
313
+ aggregation pipelines that match, you need to know the column names.
314
+
315
+ | Parse field | MongoDB column | Notes |
316
+ | ----------------------- | ---------------------- | ------------------------------------------------------------- |
317
+ | `objectId` | `_id` | String, 10 chars in the standard Parse format. |
318
+ | `createdAt` | `_created_at` | BSON Date. |
319
+ | `updatedAt` | `_updated_at` | BSON Date. |
320
+ | `ACL` | `_acl` | Short-key form: `{ "<userId>": { "r": true, "w": true } }`. |
321
+ | Pointer field `author` | `_p_author` | String `"AuthorClass$abc123"`. Embedded `__type` is *not* used in this column. |
322
+ | Array of pointers | `field` | Array of `{ __type: "Pointer", className:, objectId: }` hashes. |
323
+ | Relation | `_Join:field:Class` | Separate collection. Each row: `{ owningId:, relatedId: }`. |
324
+ | `parseReference` | `parseReference` | Optional. Mirrors `_p_` form: `"ClassName$objectId"`. See below. |
325
+ | Regular fields | `fieldName` (camelCase) | The Parse "remote name". Local snake-case is gem-side only. |
326
+
327
+ ### Field references in pipeline expressions
328
+
329
+ Inside `$match`, `$project`, `$expr`, etc., refer to fields by their
330
+ MongoDB column name with a `$` prefix:
331
+
332
+ ```ruby
333
+ { "$match" => { "$expr" => { "$gt" => ["$plays", "$threshold"] } } }
334
+ { "$project" => { "_p_author" => 1, "name" => 1, "createdAt" => "$_created_at" } }
335
+ ```
336
+
337
+ #### Pipeline-local aliases (4.4.2+)
338
+
339
+ The `$author` → `$_p_author` / `$createdAt` → `$_created_at` rewrite
340
+ inside expression values is **schema-aware**: a `$field` reference whose
341
+ name is neither a declared Parse property on the queried class nor one
342
+ of the universal built-ins (`objectId` / `createdAt` / `updatedAt`)
343
+ passes through verbatim. This means aliases introduced by an upstream
344
+ `$project` / `$addFields` / `$set` / `$group` stage survive into
345
+ downstream stages exactly as you wrote them, and result rows are keyed
346
+ by the literal spelling the caller used.
347
+
348
+ ```ruby
349
+ pipeline = [
350
+ { "$group" => { "_id" => nil,
351
+ "contributor_set" => { "$addToSet" => "$_p_user" } } },
352
+ { "$project" => { "contributor_count" => { "$size" => "$contributor_set" } } },
353
+ ]
354
+
355
+ Parse::MongoDB.aggregate("Capture", pipeline)
356
+ # => [{ "contributor_count" => 27 }]
357
+ # row keyed by the literal alias, no read-side translation needed
358
+ ```
359
+
360
+ Naming caveat: an alias whose name shadows a declared Parse property
361
+ will be resolved by the schema-aware walker as the property in
362
+ downstream stages — `$group { author: ... }` followed by a downstream
363
+ `$author` reference becomes `$_p_author` (storage column), not the
364
+ alias. Avoid alias names that collide with declared property names; the
365
+ constraint is general to MongoDB aggregation, not specific to parse-stack.
366
+
367
+ ### System classes
368
+
369
+ Parse system classes use a `_`-prefixed collection name:
370
+
371
+ - `User` → `_User`
372
+ - `Role` → `_Role`
373
+ - `Installation` → `_Installation`
374
+ - `Session` → `_Session`
375
+
376
+ Always pass the prefixed form to `Parse::MongoDB.aggregate` /
377
+ `Parse::MongoDB.find`. The `Parse::Query` path resolves the alias for
378
+ you, but the direct API does not.
379
+
380
+ ### Dates
381
+
382
+ MongoDB stores dates as BSON `Date` (UTC). Compare with Ruby `Time`
383
+ objects, which the driver serializes as BSON dates automatically:
384
+
385
+ ```ruby
386
+ cutoff = Time.utc(2024, 1, 1)
387
+ { "$match" => { "_created_at" => { "$gte" => cutoff } } }
388
+ ```
389
+
390
+ The `Parse::MongoDB.to_mongodb_date(value)` helper coerces `Date`,
391
+ `DateTime`, `Time`, ISO 8601 strings, and Unix timestamps to a UTC `Time`
392
+ suitable for matching.
393
+
394
+ ---
395
+
396
+ ## Pointer joins and `parse_reference`
397
+
398
+ Joining across Parse classes with the raw storage layout is awkward
399
+ because the local side stores `_p_author = "Author$abc123"` while the
400
+ foreign collection's `_id = "abc123"`. Three lookup forms exist; pick
401
+ based on whether the foreign class declares `parse_reference`.
402
+
403
+ ### Recommended: declare `parse_reference` on the foreign class
404
+
405
+ ```ruby
406
+ class Author < Parse::Object
407
+ property :name, :string
408
+ parse_reference
409
+ end
410
+
411
+ class Post < Parse::Object
412
+ belongs_to :author, class_name: "Author"
413
+ end
414
+ ```
415
+
416
+ `parse_reference` adds a `parseReference` column to the foreign class,
417
+ populated automatically with the canonical `"ClassName$objectId"` string.
418
+ This mirrors the local `_p_*` column format exactly, so `$lookup`
419
+ collapses to a one-field equality:
420
+
421
+ ```ruby
422
+ pipeline = [
423
+ { "$lookup" => {
424
+ "from" => "Author",
425
+ "localField" => "_p_author",
426
+ "foreignField" => "parseReference",
427
+ "as" => "author_doc",
428
+ } },
429
+ ]
430
+ Parse::MongoDB.aggregate("Post", pipeline)
431
+ ```
432
+
433
+ This form is the fastest (single equality on a hashable string column —
434
+ add an index on `parseReference` for foreign collections that grow
435
+ large) and the simplest to reason about.
436
+
437
+ #### Precompute mode
438
+
439
+ For high-write classes, declare with `precompute: true` so the canonical
440
+ value is embedded in the initial create POST and no follow-up `update!`
441
+ fires:
442
+
443
+ ```ruby
444
+ class Author < Parse::Object
445
+ parse_reference precompute: true
446
+ end
447
+ ```
448
+
449
+ The trade-off: the gem generates the `objectId` client-side
450
+ (`SecureRandom.alphanumeric(10)`, matching Parse Server's own format)
451
+ instead of letting the server assign one.
452
+
453
+ #### Backfilling existing rows
454
+
455
+ When you enable `parse_reference` on a class that already has data, run
456
+ the rake task to populate the column on every existing row:
457
+
458
+ ```
459
+ bundle exec rake parse:references:populate CLASS=Author
460
+ ```
461
+
462
+ `DRY_RUN=true` previews counts without writing; `BATCH_SIZE=N` tunes the
463
+ page size (default 100). `rake parse:references:list` lists every loaded
464
+ class that declares `parse_reference`.
465
+
466
+ ### Without `parse_reference`: the `$split` form
467
+
468
+ When the foreign class doesn't have `parseReference`, extract the
469
+ `objectId` from `_p_*` and match on the foreign `_id`:
470
+
471
+ ```ruby
472
+ pipeline = [
473
+ { "$lookup" => {
474
+ "from" => "Author",
475
+ "let" => { "aid" => {
476
+ "$arrayElemAt" => [{ "$split" => ["$_p_author", { "$literal" => "$" }] }, 1],
477
+ } },
478
+ "pipeline" => [
479
+ { "$match" => { "$expr" => { "$eq" => ["$_id", "$$aid"] } } },
480
+ ],
481
+ "as" => "author_doc",
482
+ } },
483
+ ]
484
+ ```
485
+
486
+ The `{ "$literal" => "$" }` form is required because MongoDB treats
487
+ unescaped `$` as a field reference.
488
+
489
+ ### LLM-generated pipelines: `Parse::LookupRewriter` (auto-wired)
490
+
491
+ LLMs trained on standard MongoDB syntax produce lookups against logical
492
+ class names and pretty field names:
493
+
494
+ ```ruby
495
+ # What the LLM writes -- matches nothing in raw Parse-on-Mongo storage:
496
+ llm_pipeline = [
497
+ { "$lookup" => {
498
+ "from" => "Author",
499
+ "localField" => "author",
500
+ "foreignField" => "_id",
501
+ "as" => "author_doc",
502
+ } },
503
+ ]
504
+
505
+ # Run it through Parse::MongoDB.aggregate -- the gem auto-rewrites:
506
+ Parse::MongoDB.aggregate("Post", llm_pipeline)
507
+ # Internally translated to: localField: "_p_author", foreignField: "parseReference"
508
+ ```
509
+
510
+ `Parse.rewrite_lookups = true` (the default) auto-applies the rewriter at
511
+ three entry points:
512
+
513
+ - `Parse::Query#aggregate(pipeline, ..., rewrite_lookups:)`
514
+ - `Parse::MongoDB.aggregate(class, pipeline, ..., rewrite_lookups:)`
515
+ - `Parse::Agent::Tools.aggregate(..., pipeline:, rewrite_lookups:)`
516
+
517
+ The auto path uses **`fallback: :preserve` mode** — it only rewrites
518
+ stages whose foreign class declares `parse_reference`. Lookups against
519
+ foreign classes without `parse_reference` are left untouched (they'll
520
+ return empty arrays unless the caller already wrote `_p_*` form). Use
521
+ `Parse::LookupRewriter.rewrite(pipeline, local_class:, fallback: :split)`
522
+ to get the `let`/`pipeline`/`$arrayElemAt`+`$split` fallback explicitly.
523
+
524
+ The rewriter handles:
525
+
526
+ - **Forward joins** — local `belongs_to` resolved to `_p_*`/`parseReference`.
527
+ - **Reverse joins** — when the LLM writes the inverse direction (start
528
+ from `Post`, attach `Comment`s with `_p_post` back-pointers).
529
+ - **`$split` fallback** (explicit-call mode only) — when the foreign class
530
+ doesn't declare `parse_reference`, extract the objectId from `_p_*`.
531
+ - **System-class collection rename** — `from: "User"` → `from: "_User"`.
532
+ Applied to `$lookup.from`, `$unionWith.from`/`coll`, and `$graphLookup.from`.
533
+ - **Sub-pipeline recursion** — walks into `$lookup.pipeline`,
534
+ `$unionWith.pipeline`, and `$facet.*`. (`$graphLookup` does not accept
535
+ a sub-pipeline.)
536
+ - **Idempotency** — stages already in `_p_*`/`parseReference` form pass
537
+ through unchanged, so SDK-generated pipelines (which already use the
538
+ correct columns) are not double-rewritten.
539
+
540
+ #### Controls
541
+
542
+ | Surface | Default | Override |
543
+ | ------- | ------- | -------- |
544
+ | Global | `Parse.rewrite_lookups = true` | Set `false` to disable everywhere |
545
+ | Per `Query#aggregate` | follows global | `Song.query.aggregate(pipe, rewrite_lookups: false)` |
546
+ | Per `Parse::MongoDB.aggregate` | follows global | `Parse::MongoDB.aggregate(class, pipe, rewrite_lookups: false)` |
547
+ | Per `Tools.aggregate` (agent) | follows global | tool-call `rewrite_lookups: false` |
548
+
549
+ #### Limitations
550
+
551
+ - **`$graphLookup` pointer-join translation.** Only the `from:` collection
552
+ alias is rewritten. The `connectFromField`/`connectToField` pair is not
553
+ translated to `_p_*`/`parseReference` form because the typical
554
+ `$graphLookup` use case (recursive hierarchies over the same collection)
555
+ doesn't need it. Callers using `$graphLookup` against pointer columns
556
+ must supply the Parse-on-Mongo column names themselves.
557
+ - **Polymorphic pointers** — the rewriter relies on `belongs_to :field,
558
+ class_name: "Foo"` to resolve the target class. A pointer field that
559
+ can hold instances of multiple classes is left alone.
560
+ - **Embedded pointer arrays** — array fields of pointer hashes
561
+ (`__type: "Pointer", className:, objectId:`) are not the same as
562
+ `_p_*` pointer-string columns and aren't rewriteable. The rewriter
563
+ passes them through unchanged.
564
+
565
+ ---
566
+
567
+ ## Result conversion
568
+
569
+ `Parse::MongoDB.aggregate` returns raw documents — keys are the
570
+ underscore-prefixed MongoDB column names, values are BSON types. Three
571
+ helpers convert to friendlier shapes:
572
+
573
+ - **`Parse::MongoDB.convert_documents_to_parse(docs, class_name)`** —
574
+ renames `_id` → `objectId`, `_created_at` → `createdAt`, `_p_*` →
575
+ embedded `{__type: "Pointer", ...}`, `_acl` → Parse `ACL` format.
576
+ Strips internal fields per `Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST`
577
+ — `_rperm`, `_wperm`, `_hashed_password`, `_password_history`,
578
+ `_session_token`/`_sessionToken` (the `_User`-side internal columns),
579
+ `sessionToken`/`session_token` (the `_Session`-class wire columns, added
580
+ in v4.3.0), `_email_verify_token`, `_perishable_token`,
581
+ `_failed_login_count`, `_account_lockout_expires_at`, `_tombstone`,
582
+ `_auth_data` and any `_auth_data_<provider>` prefix.
583
+ - **`Parse::MongoDB.convert_aggregation_document(doc)`** — for `$group`
584
+ rows where `_id` is the group key, not a document id. Coerces values
585
+ but preserves all keys including `_id`.
586
+ - **`Query::Aggregation#results`** — branches per-row: documents with
587
+ `_created_at`/`_updated_at` get decoded as `Parse::Object`; the rest
588
+ are wrapped as `Parse::AggregationResult` so the group key stays
589
+ accessible via `result._id` or `result.id`.
590
+
591
+ ### Embedded pointer fields
592
+
593
+ `_p_author = "Author$abc"` becomes `author = { __type: "Pointer",
594
+ className: "Author", objectId: "abc" }`. If your `$lookup` populated the
595
+ joined document into the `author_doc` array, you can `$unwind` it and
596
+ project it under the canonical pointer field name:
597
+
598
+ ```ruby
599
+ { "$lookup" => { "from" => "Author", "localField" => "_p_author",
600
+ "foreignField" => "parseReference",
601
+ "as" => "_included_author" } },
602
+ { "$unwind" => { "path" => "$_included_author", "preserveNullAndEmptyArrays" => true } },
603
+ ```
604
+
605
+ The `_included_` prefix is recognized by `convert_documents_to_parse`
606
+ and the embedded document is hoisted to the un-prefixed name on output
607
+ (`_included_author` → `author`).
608
+
609
+ ---
610
+
611
+ ## Security
612
+
613
+ Direct MongoDB bypasses Parse Server entirely. **Critical Parse Server
614
+ behavior to know:** the REST `POST /aggregate/<Class>` endpoint REQUIRES
615
+ the master key and enforces NEITHER CLP nor ACL — there is no session-
616
+ token authorization model for REST aggregate at all. So any aggregation
617
+ workload (mongo-direct OR REST aggregate through Parse Server) only gets
618
+ ACL/CLP enforcement if the SDK applies it.
619
+
620
+ As of **v4.4.0**, the SDK applies that enforcement on the mongo-direct
621
+ path when the caller supplies a scope. Five layers compose:
622
+
623
+ ### Layer 1: Pipeline-security denylist (always on)
624
+
625
+ `Parse::PipelineSecurity` refuses dangerous operators at any depth in
626
+ the pipeline — whether at the top level or nested inside
627
+ `$lookup.pipeline`, `$facet.*`, `$expr`, etc.:
628
+
629
+ - **Denied operators:** `$where`, `$function`, `$accumulator`, `$out`,
630
+ `$merge`, `$collMod`, `$createIndex`, `$dropIndex`,
631
+ `$planCacheSetFilter`, `$planCacheClear`. All execute server-side
632
+ JavaScript or mutate database state.
633
+ - **Permissive mode** (`Parse::Query#aggregate`): denylist only,
634
+ no stage-allowlist enforcement. Atlas Search, `$densify`, `$fill`,
635
+ and other uncommon read stages pass through.
636
+ - **Strict mode** (`Parse::PipelineSecurity.validate_pipeline!`): explicit
637
+ allowlist for callers that need it; this is what the LLM agent's
638
+ `aggregate` tool uses.
639
+
640
+ `$lookup`, `$graphLookup`, and `$unionWith` are NOT denied — they're
641
+ legitimate read stages — but they read from arbitrary collections. Never
642
+ pass attacker-controlled input into a pipeline; build the pipeline in
643
+ trusted code and interpolate only validated values.
644
+
645
+ ### Layer 2: Row-level ACL enforcement (`Parse::ACLScope`) — scoped only
646
+
647
+ When `Parse::MongoDB.aggregate` is called with `session_token:`,
648
+ `acl_user:`, or `acl_role:`, the SDK runs a three-step row-level ACL
649
+ simulation that matches Parse Server's REST find behavior:
650
+
651
+ 1. **Top-level `$match` injection** — filters the queried collection's
652
+ rows by the session's `_rperm` allow-set.
653
+ 2. **Pipeline rewriter** — every `$lookup` / `$unionWith` / `$graphLookup` /
654
+ `$facet` sub-pipeline gets the same `_rperm` filter embedded so joined
655
+ rows from other collections are filtered at the database. Without
656
+ this, includes/joins would silently leak rows the requesting session
657
+ has no permission to read.
658
+ 3. **Post-fetch redaction** — walks returned documents and scrubs any
659
+ embedded sub-documents whose stored `_rperm` doesn't match the
660
+ session's claim set. Catches cases the rewriter can't reach (raw
661
+ `$lookup` shapes, `:object` columns embedding pointer-shaped hashes).
662
+
663
+ ```ruby
664
+ # session-token mode — SDK round-trips /users/me to expand the user's
665
+ # roles, then injects all three layers
666
+ Parse::MongoDB.aggregate("Document", pipeline, session_token: user.token)
667
+
668
+ # acl_user mode — pre-resolved Parse::User, skips the token round-trip
669
+ Parse::MongoDB.aggregate("Document", pipeline, acl_user: current_user)
670
+
671
+ # acl_role mode — service-account scope ("see as if a user holding this
672
+ # role were asking"); no user_id in the claim set
673
+ Parse::MongoDB.aggregate("Document", pipeline, acl_role: "scope:audit")
674
+
675
+ # master mode — explicit ACL bypass for admin / analytics workloads
676
+ Parse::MongoDB.aggregate("Document", pipeline, master: true)
677
+ ```
678
+
679
+ All four kwargs are mutually exclusive — passing two raises
680
+ `ArgumentError`. Calling `aggregate` with NONE of them in a production
681
+ deployment emits a one-time `[Parse::ACLScope:SECURITY]` banner to
682
+ stderr and falls through to public-only ACL semantics. Set
683
+ `Parse::ACLScope.require_session_token = true` to make missing-auth
684
+ calls raise `Parse::ACLScope::ACLRequired` instead — recommended for
685
+ hardened deployments.
686
+
687
+ ### Layer 3: Class-Level Permissions (`Parse::CLPScope`) — scoped only
688
+
689
+ After ACL injection, the SDK consults `Parse::CLPScope.permits?` against
690
+ the queried class's `classLevelPermissions` (cached from `_SCHEMA` with
691
+ a 1-hour default TTL):
692
+
693
+ - **Boundary refusal** — `Parse::CLPScope::Denied` is raised before the
694
+ pipeline runs when the resolved scope's claim set doesn't satisfy the
695
+ class's `find` CLP. Master-key callers bypass.
696
+ - **`pointerFields` CLP** — when the class declares
697
+ `find: { pointerFields: ["owner"] }`, the SDK runs the query then
698
+ drops rows where none of the named pointer fields references the
699
+ requesting user. `acl_role`-only scopes (no user_id) are refused at
700
+ the boundary because no row can satisfy the constraint.
701
+
702
+ Cache control for long-lived processes:
703
+
704
+ ```ruby
705
+ Parse::CLPScope.cache_ttl = 3600 # default seconds
706
+ Parse::CLPScope.invalidate!("Document") # bust on schema change
707
+ Parse::CLPScope.reset_cache! # process-wide
708
+ ```
709
+
710
+ ### Layer 4: `protectedFields` stripping — scoped only
711
+
712
+ The CLP's `protectedFields` map is resolved against the session's claim
713
+ set:
714
+
715
+ - Start with the `"*"` defaults — fields stripped from everyone by
716
+ default.
717
+ - For every claim-set entry that matches a key in the map, intersect
718
+ with that entry's strip-list. Parse Server's documented behavior is
719
+ that a field is stripped only when every applicable pattern agrees
720
+ to strip it; a `role:Admin => []` entry therefore lifts all
721
+ protection for an admin-roled session.
722
+
723
+ The strip set is applied via a post-fetch walker that recurses through
724
+ every result row and every embedded sub-document. The walker reaches
725
+ `$lookup`-included rows that a top-level `$project: { ssn: 0 }` couldn't
726
+ cover.
727
+
728
+ ### Layer 5: Master-key escape hatch
729
+
730
+ `master: true` (or omitting all four identity kwargs) bypasses Layers
731
+ 2-4 entirely. Master-key has always been the explicit ACL/CLP opt-out;
732
+ the construction banner is the operator-visibility signal. Use master
733
+ mode for analytics jobs, admin tooling, and service-account workloads
734
+ that have no per-user identity to enforce against.
735
+
736
+ ### Net effect
737
+
738
+ The mongo-direct path now matches Parse Server's REST find/get/count
739
+ authorization model for scoped callers and is the SAFER aggregation
740
+ path for any user-context workload — REST aggregate has no ACL/CLP
741
+ enforcement at all, while mongo-direct with a scope gets all of it.
742
+
743
+ ### Timeouts
744
+
745
+ ```ruby
746
+ Parse::MongoDB.aggregate("Song", pipeline, max_time_ms: 5000)
747
+ Parse::MongoDB.find("Song", filter, limit: 100, max_time_ms: 5000)
748
+ ```
749
+
750
+ Plumbs into MongoDB's `maxTimeMS` option. When the driver cancels with
751
+ error code 50, the gem translates it into
752
+ `Parse::MongoDB::ExecutionTimeout(collection_name:, max_time_ms:)`.
753
+
754
+ ### Default `find` limit
755
+
756
+ `Parse::MongoDB.find` applies a `DEFAULT_FIND_LIMIT = 1000` cap when
757
+ `:limit` is omitted and warns when the cap is hit. Pass `limit: 0` for
758
+ unbounded behavior, or an explicit `limit:` to silence the warning.
759
+
760
+ ### Pointer-shape strictness (4.4.3+)
761
+
762
+ Parse stores pointer columns on disk as `"ClassName$objectId"`. A query
763
+ that passes a bare objectId string against a pointer column matches
764
+ nothing — the value's shape doesn't line up with the stored form. The
765
+ SDK normally rewrites the most common shapes (a single `Parse::Pointer`,
766
+ a `{__type: "Pointer", ...}` hash, a peer-pointer in an `$in` array) but
767
+ cannot always infer the target class from a bare string alone.
768
+
769
+ Default behavior is backwards-compatible: emit a one-shot warning via
770
+ `Parse.logger` and leave the value as-is, so the query returns zero
771
+ rows. For LLM agents and CI this is the worst failure mode — "0 results"
772
+ reads as a real answer instead of a mistake. Enable strict mode to flip
773
+ the warning into a raise:
774
+
775
+ ```ruby
776
+ Parse.strict_pointer_shapes = true # in code
777
+ # or set in the environment:
778
+ # PARSE_STRICT_POINTER_SHAPES=true
779
+ ```
780
+
781
+ With the flag on, an unresolvable pointer-shape constraint raises
782
+ `Parse::Query::PointerShapeError` with a message that names the column,
783
+ the operator, the offending value, and the accepted shapes. Recommended
784
+ for test/CI runs and any agent-driven workload; leave off in production
785
+ if you have callers relying on the legacy silent-zero behavior.
786
+
787
+ ---
788
+
789
+ ## Routing to analytics / secondary nodes
790
+
791
+ The MongoDB driver picks the node to read from based on the read
792
+ preference encoded in the connection URI. Routing direct traffic at
793
+ non-primary nodes is the standard way to keep heavy aggregations off
794
+ the cluster's write path.
795
+
796
+ ### Generic secondary
797
+
798
+ ```ruby
799
+ Parse::MongoDB.configure(
800
+ uri: "mongodb://host/parse?readPreference=secondaryPreferred",
801
+ enabled: true,
802
+ )
803
+ ```
804
+
805
+ ### MongoDB Atlas analytics nodes
806
+
807
+ In Atlas, dedicated analytics nodes are tagged with `nodeType: ANALYTICS`.
808
+ The driver routes there when the URI carries the matching read-preference
809
+ tag:
810
+
811
+ ```
812
+ mongodb+srv://analytics_ro:<pwd>@<cluster>.mongodb.net/parse?\
813
+ readPreference=secondary&\
814
+ readPreferenceTags=nodeType:ANALYTICS&\
815
+ readPreferenceTags=
816
+ ```
817
+
818
+ The **trailing empty `readPreferenceTags=`** is a fallback — if no
819
+ analytics node is reachable, the driver falls through to any secondary.
820
+ Drop the empty tag if you'd rather have the query fail than land on a
821
+ regular secondary:
822
+
823
+ ```
824
+ ...?readPreference=secondary&readPreferenceTags=nodeType:ANALYTICS
825
+ ```
826
+
827
+ Pair this with `ANALYTICS_DATABASE_URI`:
828
+
829
+ ```bash
830
+ # Production env:
831
+ ANALYTICS_DATABASE_URI="mongodb+srv://analytics_ro:...@cluster.mongodb.net/parse?readPreference=secondary&readPreferenceTags=nodeType:ANALYTICS&readPreferenceTags="
832
+ DATABASE_URI="mongodb+srv://parse_rw:...@cluster.mongodb.net/parse"
833
+ ```
834
+
835
+ `Parse::MongoDB.configure(enabled: true)` picks up the analytics URI
836
+ automatically; Parse Server keeps using `DATABASE_URI` for OLTP.
837
+
838
+ ### Role check at configure time
839
+
840
+ When `verify_role: true` (the default), `Parse::MongoDB.configure` runs a
841
+ `connectionStatus` probe against the configured URI and emits a warning
842
+ if the authenticated user has any write actions in its privilege list.
843
+ The probe is a read-only call — no writes occur.
844
+
845
+ ```
846
+ [Parse::MongoDB] WARNING: the URI configured for direct queries
847
+ authenticates a user with write privileges. The direct path is
848
+ read-only by design; using a read-only role bounds the blast radius
849
+ if caller code touches `Parse::MongoDB.client` directly.
850
+ ```
851
+
852
+ Pass `verify_role: false` to skip the check (no connection is attempted
853
+ during `configure`). Call `Parse::MongoDB.read_only?` explicitly when you
854
+ want the value:
855
+
856
+ - `true` — the user has no write actions on the configured database.
857
+ - `false` — at least one write action was found (the warning fires for
858
+ this case).
859
+ - `nil` — couldn't determine. Empty privilege list, command not
860
+ supported on this endpoint, or network failure.
861
+
862
+ `read_only?` checks the **role**, not the transport. A
863
+ `readPreference=secondary` URI with a write-capable user is still
864
+ write-capable; the driver routes writes to primary regardless of read
865
+ preference. Use a read-only Atlas user (or equivalent on self-hosted
866
+ MongoDB) to bound the blast radius.
867
+
868
+ ### Why a connection-string approach is "good enough" in practice
869
+
870
+ The combination is **read-only role + analytics-tagged URI**:
871
+
872
+ - The role (`analytics_ro` in the example) is read-only at the Atlas
873
+ user level — even if a client ignored the read preference and hit the
874
+ primary, the worst it can do is run a query there. That's a
875
+ performance concern (stealing OLTP resources), not a correctness or
876
+ security one.
877
+ - The connection string is what keeps load off the primary and
878
+ electable secondaries in the normal case.
879
+
880
+ For most analytics workloads this is enough. The read-only role bounds
881
+ the blast radius; the URI controls routing.
882
+
883
+ ### Strict isolation (when the connection string isn't enough)
884
+
885
+ If you must hard-guarantee that direct queries cannot touch primary or
886
+ electable secondaries — for example, because you want to give the
887
+ endpoint to an external team or an autonomous agent — the connection
888
+ string alone is insufficient. The options:
889
+
890
+ - **Atlas SQL / BI Connector.** Issue the user a JDBC/SQL endpoint that
891
+ Atlas pre-pins to analytics nodes. The endpoint can't be used to hit
892
+ any other node.
893
+ - **Atlas Data Federation.** Define a federated database backed
894
+ exclusively by the analytics node tier; expose only that endpoint to
895
+ the consumer.
896
+
897
+ Both options trade flexibility (the consumer no longer has the full
898
+ MongoDB driver API) for hard isolation. Use them when the consumer is
899
+ untrusted; stay with the URI-routing approach when the consumer is your
900
+ own application code.
901
+
902
+ ### Per-query read preference (Parse Server REST path only)
903
+
904
+ `Query#read_pref` is also available for queries routed through Parse
905
+ Server's REST aggregate endpoint, which forwards the value as the
906
+ `readPreference` query option. It does NOT apply to direct MongoDB —
907
+ direct reads always use the read preference baked into the connection
908
+ URI.
909
+
910
+ ---
911
+
912
+ ## Index management
913
+
914
+ The reader URI configured via `Parse::MongoDB.configure` is read-only
915
+ by policy. A second, separately-credentialed **writer URI** is used
916
+ exclusively for MongoDB index management (and any future maintenance
917
+ write tooling). The writer is opt-in, off by default, and gated by
918
+ three independent flags that every mutation re-checks per call.
919
+
920
+ ### Reader-side primitives (always available)
921
+
922
+ - **`Parse::MongoDB.indexes(collection_name)`** — returns the raw
923
+ index definitions on a collection. Used by `Model.describe(:indexes)`
924
+ and by the migrator's plan path. Returns `[]` on `NamespaceNotFound`
925
+ (collections that haven't been created yet).
926
+ - **`Parse::MongoDB.list_search_indexes(collection_name)`** — Atlas
927
+ Search indexes only (different mechanism — `$listSearchIndexes`).
928
+ - **`Parse::MongoDB.index_stats(collection_name)`** — per-index ops
929
+ counters via `$indexStats`. Returns `Hash{name => {ops:, since:}}`.
930
+ Requires `clusterMonitor` privilege on the reader; returns `{}`
931
+ when not granted so callers degrade gracefully.
932
+
933
+ ### Model DSL: `mongo_index` / `mongo_geo_index` / `mongo_relation_index`
934
+
935
+ Index declarations are class-level metadata on `Parse::Object`
936
+ subclasses. They run validation at registration time so a typo,
937
+ unknown field, parallel-array compound, or `_id` reference fails
938
+ when the class loads.
939
+
940
+ ```ruby
941
+ class Car < Parse::Object
942
+ property :make, :string
943
+ property :model, :string
944
+ property :year, :integer
945
+ property :tags, :array
946
+ property :location, :geopoint
947
+ belongs_to :owner, as: :user
948
+ has_many :drivers, through: :relation, as: :user
949
+ parse_reference
950
+
951
+ mongo_index :make, :model, :year # compound
952
+ mongo_index :vin, unique: true
953
+ mongo_index :owner # pointer auto-rewrites to _p_owner
954
+ mongo_geo_index :location # 2dsphere on GeoJSON Point
955
+ mongo_index :tags # array field
956
+ mongo_relation_index :drivers, bidirectional: true
957
+ # → _Join:drivers:Car { owningId: 1 } and { relatedId: 1 }
958
+ end
959
+ ```
960
+
961
+ Validation rules enforced at declaration time:
962
+
963
+ - Unknown field → `ArgumentError`. `mongo_index :nonexistent_field` fails at load.
964
+ - Parallel arrays → `ArgumentError`. A compound declaration that includes
965
+ more than one array-typed field (including the Parse-managed
966
+ `_rperm` / `_wperm`) raises with "cannot index parallel arrays".
967
+ - Relation fields on the wrong DSL → `ArgumentError`. `mongo_index :drivers`
968
+ is rejected because relations live in a separate `_Join:` collection;
969
+ use `mongo_relation_index :drivers`.
970
+ - `_id` declarations → `ArgumentError`. The MongoDB primary key index
971
+ (`_id_`) is auto-managed and protected from modification.
972
+ - `expire_after` on compound → `ArgumentError`. TTL indexes only support
973
+ single-field declarations per MongoDB's rules.
974
+ - `unique:` on `mongo_relation_index` → `ArgumentError`. A
975
+ single-direction unique on a `has_many :through: :relation` would
976
+ contradict `has_many` semantics. For no-duplicate-pair membership,
977
+ declare a compound unique index directly via `Parse::MongoDB.create_index`.
978
+
979
+ `parse_reference` auto-registers a unique-sparse index declaration on
980
+ the configured field (the synchronize_create correctness floor relies
981
+ on this index existing). The sparse flag ensures `populate_parse_references!`
982
+ backfill workflows are not blocked by multiple NULLs colliding on the
983
+ unique constraint. Opt out per-field:
984
+
985
+ ```ruby
986
+ class Author < Parse::Object
987
+ parse_reference # default: unique+sparse index registered
988
+ # parse_reference unique_index: false # index without unique constraint
989
+ # parse_reference index: false # no index registered
990
+ end
991
+ ```
992
+
993
+ ### Migrator: `indexes_plan` (dry-run) / `apply_indexes!` (mutate)
994
+
995
+ `Parse::Schema::IndexMigrator` reconciles declared indexes against the
996
+ actual MongoDB state. The plan classifies each declaration into
997
+ `to_create`, `in_sync`, or `conflicts`. Comparison is by **key
998
+ signature**, not by name — MongoDB's auto-generated `field_dir_field_dir`
999
+ names align with declarations that didn't pass `name:` explicitly.
1000
+
1001
+ ```ruby
1002
+ # Dry-run — reader-only, doesn't need writer config:
1003
+ Car.indexes_plan
1004
+ # => { "Car" => { to_create: [...], in_sync: [...], orphans: [...],
1005
+ # conflicts: [...], parse_managed: [...],
1006
+ # capacity_used: 8, capacity_after: 13,
1007
+ # capacity_remaining: 51, capacity_ok: true },
1008
+ # "_Join:drivers:Car" => { ... } }
1009
+
1010
+ # Apply — additive by default, requires writer + triple gate:
1011
+ Car.apply_indexes!
1012
+ # => { "Car" => { created: [...], skipped_exists: [...],
1013
+ # dropped: [], conflicts: [], capacity_blocked: false },
1014
+ # "_Join:drivers:Car" => { ... } }
1015
+
1016
+ # Opt-in drops:
1017
+ Car.apply_indexes!(drop: true) # drops orphans (per-call confirmation envelope)
1018
+ ```
1019
+
1020
+ Plan is a Hash keyed by target collection — one entry per unique
1021
+ collection across the declaration list. The parent collection
1022
+ (`Car`) and any join collections (`_Join:drivers:Car`) are reported
1023
+ separately so drift is detectable per collection.
1024
+
1025
+ The migrator never proposes drops against Parse-managed indexes
1026
+ (`_id_`, `_username_unique`, `_email_unique`, `_session_token_*`,
1027
+ `_email_verify_token_*`, `_perishable_token_*`, `_account_lockout_*`,
1028
+ `case_insensitive_*`). They appear in `parse_managed:` for
1029
+ transparency but are excluded from `orphans:` regardless of `drop:`.
1030
+
1031
+ The migrator also enforces the 64-indexes-per-collection MongoDB
1032
+ limit at plan time. `apply!` returns `{capacity_blocked: true, ...}`
1033
+ when projected `existing + to_create` would exceed 64, without
1034
+ issuing any creates.
1035
+
1036
+ ### Writer URI + triple-gate
1037
+
1038
+ `Parse::MongoDB.configure_writer(uri:, enabled: true, verify_role: true)`
1039
+ opens a second `Mongo::Client` against a write-capable role URI. The
1040
+ writer is the only path through which index mutations reach MongoDB.
1041
+ The underlying client is held privately — there's no public accessor.
1042
+
1043
+ ```ruby
1044
+ # Typically in a rake-task initializer, NEVER in a web-process initializer:
1045
+ Parse::MongoDB.configure_writer(uri: ENV["MONGO_WRITER_URI"])
1046
+ Parse::MongoDB.index_mutations_enabled = true
1047
+ # ENV["PARSE_MONGO_INDEX_MUTATIONS"] = "1" is also required (see below)
1048
+ ```
1049
+
1050
+ Operator-safety checks:
1051
+
1052
+ - The writer URI must be **string-distinct** from the reader URI
1053
+ (`Parse::MongoDB.uri`). Catches `configure_writer(uri: ENV["DATABASE_URI"])`
1054
+ copy-paste mistakes.
1055
+ - `verify_role: true` runs `connectionStatus` against the writer
1056
+ URI and **refuses fail-closed** (`WriterRoleTooPermissive`) if the
1057
+ authenticated user holds any action outside the writer allowlist
1058
+ (`createIndex`, `dropIndex`, plus a small set of read actions for
1059
+ introspection). Override with `verify_role: false` for test fixtures
1060
+ only.
1061
+
1062
+ Every mutation re-checks **all three gates** on every call — not just
1063
+ at configure time, so a SIGHUP / supervisor env flip can revoke
1064
+ without restart:
1065
+
1066
+ 1. `Parse::MongoDB.configure_writer` was called and is enabled
1067
+ 2. `Parse::MongoDB.index_mutations_enabled == true` (default `false`,
1068
+ must be flipped in code)
1069
+ 3. `ENV["PARSE_MONGO_INDEX_MUTATIONS"] == "1"`
1070
+
1071
+ Missing any gate → raises with a message naming the missing lever:
1072
+ `WriterNotConfigured` for gate 1, `MutationsDisabled` for gates 2 / 3.
1073
+
1074
+ ### Mutation primitives (writer-only)
1075
+
1076
+ The migrator drives all mutations through these, but they're also
1077
+ directly callable:
1078
+
1079
+ ```ruby
1080
+ Parse::MongoDB.create_index("Song", { title: 1, artist: 1 }, name: "title_artist")
1081
+ # => :created (or :exists when an identical spec already exists)
1082
+
1083
+ Parse::MongoDB.drop_index("Song", "old_index",
1084
+ confirm: "drop:Song:old_index")
1085
+ # => :dropped (or :absent when the index isn't present — idempotent)
1086
+ ```
1087
+
1088
+ `drop_index` requires the `confirm:` string to equal
1089
+ `"drop:#{collection}:#{name}"` literally. Stops accidental drops from
1090
+ re-running a rake task after a context switch (wrong env shell, stale
1091
+ terminal).
1092
+
1093
+ Mutations are denied against Parse-internal collections (`_User`,
1094
+ `_Role`, `_Session`, `_Installation`, `_Audience`, `_Idempotency`,
1095
+ `_PushStatus`, `_JobStatus`, `_Hooks`, `_GlobalConfig`, `_SCHEMA`)
1096
+ unless `allow_system_classes: true` is passed explicitly. The
1097
+ migrator passes this automatically for `_Join:*` collections only
1098
+ (joins themselves aren't on the denylist, but their parent class
1099
+ might be — e.g. `_Join:users:_Role`).
1100
+
1101
+ Every writer event emits a `[Parse::MongoDB:WRITER]` audit line with
1102
+ the event kind, collection, PID, and operation-specific fields. Match
1103
+ the `[Parse::Agent:SECURITY]` style used elsewhere in the gem.
1104
+
1105
+ #### Atlas Search index primitives
1106
+
1107
+ The writer also exposes parallel primitives for managing Atlas Search
1108
+ indexes. Same triple-gate, same denylist, same audit channel — but
1109
+ the commands are `createSearchIndexes` / `dropSearchIndex` /
1110
+ `updateSearchIndex` (sent via `database.command`, since Atlas Search
1111
+ indexes are not regular Mongo indexes). The writer role must hold
1112
+ the corresponding privileges, all of which are present in
1113
+ `WRITER_ALLOWED_ACTIONS`.
1114
+
1115
+ ```ruby
1116
+ # Submit an index. The Atlas Search build runs ASYNC on the search
1117
+ # node; this returns as soon as the command is accepted.
1118
+ Parse::MongoDB.create_search_index(
1119
+ "Song", "song_search",
1120
+ { mappings: { dynamic: false, fields: { title: { type: "string" } } } },
1121
+ )
1122
+ # => :created (or :exists when an index of that name already exists)
1123
+
1124
+ # Replace the definition of an existing index. Same async rebuild.
1125
+ Parse::MongoDB.update_search_index(
1126
+ "Song", "song_search",
1127
+ { mappings: { dynamic: true } },
1128
+ )
1129
+ # => :updated (raises ArgumentError when no index by that name exists)
1130
+
1131
+ # Drop. Confirm token uses the "drop_search:" prefix (deliberately
1132
+ # distinct from "drop:" so a token meant for a regular index cannot
1133
+ # be replayed against a search index of the same name, and vice versa).
1134
+ Parse::MongoDB.drop_search_index(
1135
+ "Song", "song_search",
1136
+ confirm: "drop_search:Song:song_search",
1137
+ )
1138
+ # => :dropped (or :absent — idempotent)
1139
+ ```
1140
+
1141
+ Idempotency on `create_search_index` is name-based, not
1142
+ definition-based: a duplicate-name create silently returns `:exists`
1143
+ without diffing the mapping. To change a definition, call
1144
+ `update_search_index` explicitly.
1145
+
1146
+ Use `Parse::AtlasSearch::IndexManager.{create_index,drop_index,update_index}`
1147
+ as wrappers when you want the IndexManager's status cache to be
1148
+ invalidated automatically. The bare `Parse::MongoDB.*` primitives do
1149
+ not touch that cache — direct callers must
1150
+ `Parse::AtlasSearch::IndexManager.clear_cache(collection_name)`
1151
+ themselves.
1152
+
1153
+ #### Waiting for an async Atlas Search build
1154
+
1155
+ Atlas Search builds transition through `BUILDING` to `READY`. The
1156
+ documented anti-pattern is `until index_ready?; sleep 2; end` — the
1157
+ IndexManager's 300-second status cache locks in the first
1158
+ `queryable: false` reading and never sees the transition. Use the
1159
+ helper:
1160
+
1161
+ ```ruby
1162
+ case Parse::AtlasSearch::IndexManager.wait_for_ready(
1163
+ "Song", "song_search", timeout: 600, interval: 5,
1164
+ )
1165
+ when :ready then # index is queryable
1166
+ when :failed then raise "search index build failed"
1167
+ when :timeout then raise "did not reach READY within 600s"
1168
+ end
1169
+ ```
1170
+
1171
+ `wait_for_ready` passes `force_refresh: true` to `list_indexes` on
1172
+ every poll, so the cache cannot lock in the BUILDING state.
1173
+
1174
+ #### Atlas Search DSL: `mongo_search_index`
1175
+
1176
+ For declarative provisioning (analogous to `mongo_index`):
1177
+
1178
+ ```ruby
1179
+ class Song < Parse::Object
1180
+ property :title, :string
1181
+ property :artist, :string
1182
+
1183
+ mongo_search_index "song_search", {
1184
+ mappings: { dynamic: false, fields: {
1185
+ title: { type: "string", analyzer: "lucene.standard" },
1186
+ artist: { type: "string" },
1187
+ } },
1188
+ }
1189
+ end
1190
+
1191
+ Song.search_indexes_plan
1192
+ # => { collection: "Song", declared: [...], existing: [...],
1193
+ # atlas_available: true, to_create: [...], in_sync: [...],
1194
+ # drifted: [...], orphans: [...] }
1195
+
1196
+ Song.apply_search_indexes! # additive only
1197
+ Song.apply_search_indexes!(update: true) # also rebuild drifted
1198
+ Song.apply_search_indexes!(drop: true) # also drop orphans
1199
+ Song.apply_search_indexes!(wait: true, timeout: 600) # block until READY
1200
+ ```
1201
+
1202
+ `mongo_search_index` accepts a third `type:` kwarg (`"search"` default,
1203
+ or `"vectorSearch"`). Multiple declarations per class are supported
1204
+ — each must use a unique name. Same-name redeclaration is idempotent
1205
+ on identical content; redeclaration with a different definition or
1206
+ type raises at class-load.
1207
+
1208
+ Drift detection is **detect-and-refuse**, never auto-apply. A
1209
+ declared definition that diverges from Atlas's `latestDefinition`
1210
+ appears in `:drifted` and is reported only — the operator opts into
1211
+ the rebuild with `update: true`. Mapping diff is fragile (Atlas may
1212
+ normalize defaults), so an over-eager auto-update would silently
1213
+ rebuild production indexes on every deploy.
1214
+
1215
+ Orphan handling is **report-only by default**. Search indexes
1216
+ present on the collection but not declared via `mongo_search_index`
1217
+ appear in `:orphans` and are dropped only under `drop: true`. Each
1218
+ drop carries its own `drop_search:<coll>:<name>` confirm-token
1219
+ envelope automatically; you don't supply the token at the DSL level.
1220
+
1221
+ ### Rake tasks
1222
+
1223
+ ```bash
1224
+ # Dry-run across every Parse::Object subclass that declares mongo_index:
1225
+ rake parse:mongo:indexes:plan
1226
+
1227
+ # Filter to one class:
1228
+ rake parse:mongo:indexes:plan CLASS=Car
1229
+
1230
+ # Apply additive changes (requires writer URI + index_mutations_enabled + env var):
1231
+ PARSE_MONGO_INDEX_MUTATIONS=1 rake parse:mongo:indexes:apply
1232
+
1233
+ # Also drop orphans:
1234
+ PARSE_MONGO_INDEX_MUTATIONS=1 DROP=true rake parse:mongo:indexes:apply
1235
+
1236
+ # Search-index counterparts (require mongo_search_index declarations):
1237
+ rake parse:mongo:search_indexes:plan
1238
+ PARSE_MONGO_INDEX_MUTATIONS=1 rake parse:mongo:search_indexes:apply
1239
+ PARSE_MONGO_INDEX_MUTATIONS=1 UPDATE=true rake parse:mongo:search_indexes:apply # rebuild drifted
1240
+ PARSE_MONGO_INDEX_MUTATIONS=1 DROP=true rake parse:mongo:search_indexes:apply # drop orphans
1241
+ PARSE_MONGO_INDEX_MUTATIONS=1 WAIT=true rake parse:mongo:search_indexes:apply # block until READY
1242
+ ```
1243
+
1244
+ The `apply` task re-states all three gates up-front with
1245
+ operator-readable error messages so a missing configuration surfaces
1246
+ as one readable failure rather than N stack traces. The
1247
+ `search_indexes:apply` task uses the same three gates, plus `UPDATE`
1248
+ / `DROP` / `WAIT` / `WAIT_TIMEOUT` env vars to control drift /
1249
+ orphan / readiness behavior.
1250
+
1251
+ ### Inspection via `Model.describe(:indexes, network: true)`
1252
+
1253
+ ```ruby
1254
+ Car.describe(:indexes, network: true)
1255
+ # => { class_name: "Car",
1256
+ # indexes: {
1257
+ # available: true, count: 7,
1258
+ # indexes: [ { name: "_id_", implicit_id: true, key: {"_id" => 1}, ... }, ... ],
1259
+ # declared: [...], drift: { to_create: [...], in_sync: [...], orphans: [...], conflicts: [...] },
1260
+ # parse_managed: ["_id_"],
1261
+ # capacity: { used: 7, after: 7, remaining: 57, ok: true },
1262
+ # relations: { "_Join:drivers:Car" => { ... } } } }
1263
+
1264
+ # Optional $indexStats usage counters:
1265
+ Car.describe(:indexes, network: true, usage: true)
1266
+ # Each index entry gains a :usage sub-hash with ops + since timestamp.
1267
+ # Top-level :usage_available reports whether the role can run $indexStats.
1268
+ ```
1269
+
1270
+ ---
1271
+
1272
+ ## Quick start
1273
+
1274
+ ```ruby
1275
+ # 1. Gemfile
1276
+ # gem "mongo", "~> 2.18"
1277
+
1278
+ # 2. Connect (resolves ANALYTICS_DATABASE_URI, falling back to DATABASE_URI)
1279
+ require "parse/mongodb"
1280
+ Parse::MongoDB.configure(enabled: true)
1281
+
1282
+ # 3. Model
1283
+ class Post < Parse::Object
1284
+ property :title, :string
1285
+ belongs_to :author, class_name: "Author"
1286
+ end
1287
+
1288
+ class Author < Parse::Object
1289
+ property :name, :string
1290
+ parse_reference
1291
+ end
1292
+
1293
+ # 4. Query
1294
+ posts_with_authors = Parse::MongoDB.aggregate("Post", [
1295
+ { "$lookup" => {
1296
+ "from" => "Author",
1297
+ "localField" => "_p_author",
1298
+ "foreignField" => "parseReference",
1299
+ "as" => "_included_author",
1300
+ } },
1301
+ { "$unwind" => { "path" => "$_included_author",
1302
+ "preserveNullAndEmptyArrays" => true } },
1303
+ { "$limit" => 100 },
1304
+ ])
1305
+
1306
+ # 5. Convert
1307
+ Parse::MongoDB.convert_documents_to_parse(posts_with_authors, "Post").each do |doc|
1308
+ puts "#{doc["title"]} by #{doc.dig("author", "name")}"
1309
+ end
1310
+ ```
1311
+
1312
+ ---
1313
+
1314
+ ## Troubleshooting
1315
+
1316
+ **`Parse::MongoDB::GemNotAvailable`.** The `mongo` gem isn't installed.
1317
+ Add it to your Gemfile.
1318
+
1319
+ **`Parse::MongoDB::NotEnabled`.** You forgot
1320
+ `Parse::MongoDB.configure(uri:, enabled: true)` or `enabled: false` was
1321
+ passed.
1322
+
1323
+ **`$lookup` returns empty arrays.** Either the `localField` isn't
1324
+ `_p_*` (you wrote the logical Parse name, not the MongoDB column), or
1325
+ the foreign class doesn't have `parseReference` populated (declare
1326
+ `parse_reference` and backfill via rake, or switch to the `$split`
1327
+ form).
1328
+
1329
+ **Pipeline returns documents but `convert_documents_to_parse` strips
1330
+ fields.** Internal fields starting with `_` (other than `_id`,
1331
+ `_p_*`, `_created_at`, `_updated_at`, `_acl`, `_included_*`) are
1332
+ dropped intentionally. Project them under a non-underscore name in the
1333
+ pipeline.
1334
+
1335
+ **`Parse::MongoDB::DeniedOperator`.** A `$where` / `$function` /
1336
+ `$accumulator` / mutation operator appeared somewhere in the pipeline.
1337
+ The validator walks recursively; the operator might be nested deep
1338
+ inside `$facet` or `$lookup.pipeline`.
1339
+
1340
+ **`Parse::MongoDB::ExecutionTimeout`.** The query exceeded the
1341
+ `max_time_ms` budget. Narrow the filter, add an index, or raise the
1342
+ budget.
1343
+
1344
+ **Aggregation results come back missing fields.** When `$group` is in
1345
+ the pipeline, the resulting `_id` is the group key, not a document id.
1346
+ Use `Query::Aggregation#results` (which returns
1347
+ `Parse::AggregationResult` instances) instead of decoding rows as
1348
+ `Parse::Object`.