parse-stack-next 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,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`.
|