parse-stack-next 5.3.0 → 5.4.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 +4 -4
- data/.gitignore +2 -0
- data/CHANGELOG.md +461 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +160 -3
- data/Rakefile +52 -3
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +5 -0
- data/docs/mcp_guide.md +59 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +55 -2
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +180 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/querying.rb +3 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +17 -0
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +252 -7
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +15 -1
data/README.md
CHANGED
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [parse-stack-next](https://github.com/neurosynq/parse-stack-next) is a Ruby client SDK, REST client, and Active Model ORM for [Parse Server](http://parseplatform.org/), combining a low-level API client, a query engine, an object-relational mapper (ORM), and a Cloud Code Webhooks rack application in a single gem.
|
|
6
6
|
|
|
7
|
+
### What's new in 5.4
|
|
8
|
+
|
|
9
|
+
- **5.4.0 — Hybrid search + reranking for RAG** — `Class.hybrid_search(text:, lexical:, vector:, k:, fusion:)` fuses a lexical Atlas Search branch with a `$vectorSearch` branch using reciprocal-rank fusion (RRF): lexical search nails exact tokens (codes, proper nouns), vector search nails paraphrase, and fusing the two beats either alone. Each branch enforces ACL/CLP independently before fusion (no separate hydration fetch to secure); results carry `#hybrid_score` / `#hybrid_ranks`. `Parse::VectorSearch::Hybrid.rank_fusion_supported?` detects Atlas 8.0+ native `$rankFusion` by a cached behavioural probe (native execution is opt-in; client-side RRF is the always-enforced default). `Parse::Retrieval::Reranker` adds cross-encoder reranking (`Reranker::Cohere` over `/v2/rerank`, plus a deterministic `Reranker::Fixture`), wired into `Parse::Retrieval.retrieve(hybrid:, rerank:)`. `Parse::Embeddings::SpendCap` adds an opt-in per-tenant embedding token cap (hard-refuse) at the `semantic_search` agent-tool boundary. See [CHANGELOG.md](./CHANGELOG.md) and [`docs/atlas_vector_search_guide.md`](./docs/atlas_vector_search_guide.md)
|
|
10
|
+
- **5.4.0 — Vector backfill, visibility, and webhook redaction** — `Class.embed_pending!` backfills embeddings for records whose managed `:vector` field is null (objectId-cursor pagination); `Parse::Object#compute_embedding!` forces an in-place recompute without a save; `vector_visibility :owner_only | :public` controls whether a class's vectors appear in `as_json` by default; and webhook trigger payloads now strip declared `:vector` columns by default (a `:public` class keeps them). See [CHANGELOG.md](./CHANGELOG.md)
|
|
11
|
+
- **5.4.0 — TOTP multi-factor auth works end to end** — the `Parse::User` MFA lifecycle is now fully functional and exercised against a real MFA-enabled Parse Server. `setup_mfa!(secret:, token:)` enrolls TOTP and returns recovery codes; `Parse::User.login_with_mfa(user, pass, code)` completes a second-factor login; `mfa_enabled?` / `mfa_status` report enrollment after an ordinary fetch — the SDK strips the raw TOTP secret and recovery codes that Parse Server returns in `authData` but preserves a leak-safe `{status: "enabled"}` projection so the status reads correctly without exposing the secret; `disable_mfa!(current_token:)` turns MFA off after re-validating the current code (a wrong code raises `Parse::MFA::VerificationError`), and `disable_mfa_master_key!(authorized_by:)` is the operator override. Each MFA write also no longer raises an internal argument error before reaching the server. Interactively, `rake client:console` now prompts for a TOTP / recovery code (or reads `PARSE_LOGIN_MFA`) when logging into an enrolled account. See [CHANGELOG.md](./CHANGELOG.md)
|
|
12
|
+
- **5.4.0 — Request email-address verification** — `Parse::User.request_email_verification(email)` and the instance `Parse::User#request_email_verification` ask Parse Server to (re)send the verification email for a registered, not-yet-verified user, mirroring `request_password_reset` (per-email rate limiting, Boolean return). Requires a server email adapter with `verifyUserEmails` enabled. See [CHANGELOG.md](./CHANGELOG.md)
|
|
13
|
+
- **5.4.0 — Audience hash queries persist correctly** — `Parse::Audience#query` is now stored as a JSON string on the wire to match Parse Server's `_Audience.query` column type, so saving an audience with a `Hash` query no longer fails the server schema check. The public API is unchanged — assign a `Hash`, read a `Hash` back. See [CHANGELOG.md](./CHANGELOG.md)
|
|
14
|
+
- **5.4.0 — Faster AtlasSearch role-cache expiry** — `Parse::AtlasSearch` `role_cache_ttl` now defaults to 30 seconds (was 120) so a role grant or revoke is reflected in `$search` ACL decisions sooner, at the cost of slightly more frequent role lookups. See [CHANGELOG.md](./CHANGELOG.md)
|
|
15
|
+
|
|
7
16
|
### What's new in 5.3
|
|
8
17
|
|
|
9
18
|
- **5.3.0 — Run webhook handlers (and clients) as the calling user** — Parse Server embeds the caller's live session token in every trigger webhook fired by a logged-in user. A handler can now opt in to acting on the server *as that user* — full ACL/CLP/`protectedFields` enforcement, no master key. `payload.session_token` exposes the captured token (`nil` for master-key requests; still scrubbed from `payload.user`/`payload.object`/`as_json`/logs); `payload.user_agent` returns a client-mode `Parse::Agent`, and `payload.user_client` a non-master `Parse::Client` with the token **bound** so even raw REST calls authorize as the user. The same user-scoped client is available client-side via `Parse::User#session_client` and the `Parse::Client#become(token)` primitive, with `Parse::Client#with_session { … }` for block scoping. Backed by a new `Parse::Client.new(session_token:)` option. See [Acting as the calling user](#acting-as-the-calling-user)
|
|
@@ -209,9 +218,20 @@ result = Parse.call_function :myFunctionName, {param: value}
|
|
|
209
218
|
|
|
210
219
|
```
|
|
211
220
|
|
|
221
|
+
## Examples
|
|
222
|
+
|
|
223
|
+
Runnable, self-contained scripts live in [`examples/`](examples/) — see
|
|
224
|
+
[`examples/README.md`](examples/README.md) for the full index. Highlights:
|
|
225
|
+
|
|
226
|
+
- [`basic_server.rb`](examples/basic_server.rb) — master-key setup: models, schema, CRUD + queries.
|
|
227
|
+
- [`basic_client.rb`](examples/basic_client.rb) — unprivileged client with row-level ACL enforcement.
|
|
228
|
+
- [`live_query_listener.rb`](examples/live_query_listener.rb) — interactive LiveQuery console scoped to a user's session.
|
|
229
|
+
- [`rag_chatbot.rb`](examples/rag_chatbot.rb) — retrieval-augmented generation with `semantic_search` + an OpenAI/Anthropic add-in.
|
|
230
|
+
- [`transaction_example.rb`](examples/transaction_example.rb) — atomic multi-object transactions.
|
|
231
|
+
|
|
212
232
|
## Release History
|
|
213
233
|
|
|
214
|
-
**Current version: 5.0
|
|
234
|
+
**Current version: 5.4.0** | **Ruby 3.2+ required**
|
|
215
235
|
|
|
216
236
|
The 5.0 highlights (vector search / RAG, pooled Redis cache, AS::N instrumentation, MCP transport hardening, GraphQL type generation) are summarized in the [What's new in 5.0](#whats-new-in-50) section above. Earlier releases are recorded below.
|
|
217
237
|
|
|
@@ -1586,8 +1606,11 @@ user.mfa_status # => :enabled, :disabled, or :unknown
|
|
|
1586
1606
|
# Disable MFA (requires current token)
|
|
1587
1607
|
user.disable_mfa!(current_token: "123456")
|
|
1588
1608
|
|
|
1589
|
-
# Admin reset (master key) —
|
|
1590
|
-
|
|
1609
|
+
# Admin reset (master key) — fails closed: pass either an admin_role:
|
|
1610
|
+
# for the library to verify, or allow_unverified: true to assert that
|
|
1611
|
+
# you have already authorized the operator out-of-band.
|
|
1612
|
+
user.disable_mfa_master_key!(authorized_by: admin_user, admin_role: "Admin")
|
|
1613
|
+
# or: user.disable_mfa_master_key!(authorized_by: admin_user, allow_unverified: true)
|
|
1591
1614
|
```
|
|
1592
1615
|
|
|
1593
1616
|
**SMS MFA (requires Parse Server SMS callback):**
|
|
@@ -4917,6 +4940,32 @@ The `parse_object` handed to your handler is the **full object as Parse Server s
|
|
|
4917
4940
|
|
|
4918
4941
|
For any `after_*` hook, return values are not needed since Parse does not utilize them. You may also register as many `after_save` or `after_delete` handlers as you prefer, all of them will be called.
|
|
4919
4942
|
|
|
4943
|
+
For `before_save` (and functions), the handler's value **is** the response Parse Server acts on — return the (possibly mutated) `parse_object` to allow the write, or `false` / `error!` to reject it. You can set that value with an explicit `return` or as the block's last expression; both work, as do the proc idioms `next value` / `break value`:
|
|
4944
|
+
|
|
4945
|
+
```ruby
|
|
4946
|
+
Parse::Webhooks.route :before_save, :Artist do
|
|
4947
|
+
artist = parse_object
|
|
4948
|
+
return artist if artist.name.present? # explicit early return
|
|
4949
|
+
error! "name is required" # raise to reject the save
|
|
4950
|
+
end
|
|
4951
|
+
```
|
|
4952
|
+
|
|
4953
|
+
`self` inside the block is the `Parse::Webhooks::Payload`, so `parse_object`, `params`, and `error!` are available directly. As anywhere in Ruby, `return` ends the handler immediately — to run work *after* the response is sent, use `after_response` (below) rather than code after the `return`.
|
|
4954
|
+
|
|
4955
|
+
#### Deferring work until after the response
|
|
4956
|
+
|
|
4957
|
+
`payload.after_response { … }` (alias `defer`) registers work to run **after** the webhook response has been sent, off the critical path of the save or function the client is waiting on. The handler returns its value synchronously; the deferred block runs afterward — ideal for search indexing, cache warming, or fan-out that should not add latency.
|
|
4958
|
+
|
|
4959
|
+
```ruby
|
|
4960
|
+
Parse::Webhooks.route :after_save, :Post do
|
|
4961
|
+
post = parse_object
|
|
4962
|
+
after_response { SearchIndex.reindex(post.id) } # runs after the reply is sent
|
|
4963
|
+
post
|
|
4964
|
+
end
|
|
4965
|
+
```
|
|
4966
|
+
|
|
4967
|
+
Under Puma or Unicorn the block runs via `rack.after_reply` once the response is flushed (same worker thread, zero added round-trip latency); on a server without it (e.g. WEBrick) it falls back to a detached thread. Multiple blocks run in order and are isolated — one raising affects neither the response nor the others. Notes: deferred blocks run **only on the success path** (a rejected `before_save` runs none), "after the response" is **not** "after the row commits" (don't rely on the persisted row inside the block), and the work is **in-process and best-effort** — it dies with the worker, so for anything that *must* happen use a durable job queue (Sidekiq / ActiveJob). Blocks are drained only when the payload runs through the mounted `Parse::Webhooks` Rack app (a no-op under direct `run_function` / `call_route`). See the [Cloud Code Webhooks Guide](docs/webhooks_guide.md#deferring-work-until-after-the-response).
|
|
4968
|
+
|
|
4920
4969
|
> **Your model's `after_save` callbacks run here too.** When an `after_save` /
|
|
4921
4970
|
> `after_create` trigger fires, the webhook rebuilds the `Parse::Object` from the
|
|
4922
4971
|
> payload and runs that model's ActiveModel `after_save` / `after_create`
|
|
@@ -4928,6 +4977,57 @@ For any `after_*` hook, return values are not needed since Parse does not utiliz
|
|
|
4928
4977
|
> for saves from other clients (JS / iOS / REST), the webhook runs them, since
|
|
4929
4978
|
> the SDK never had the chance.
|
|
4930
4979
|
|
|
4980
|
+
#### ActiveModel callbacks vs. Parse Server triggers
|
|
4981
|
+
|
|
4982
|
+
The SDK exposes the full ActiveModel lifecycle on every model
|
|
4983
|
+
(`before_validation`, `before_save`/`after_save`, `before_create`/`after_create`,
|
|
4984
|
+
`before_update`/`after_update`, `before_destroy`/`after_destroy`). Parse Server,
|
|
4985
|
+
separately, exposes a fixed set of **webhook trigger types**. They are not
|
|
4986
|
+
one-to-one — the SDK maps between them, and a webhook must be **registered** for
|
|
4987
|
+
your ActiveModel logic to run server-side for non-Ruby clients (JS / iOS / REST /
|
|
4988
|
+
Dashboard). Without a registered webhook, that logic runs only in the Ruby
|
|
4989
|
+
process that initiated the save.
|
|
4990
|
+
|
|
4991
|
+
Supported Parse Server trigger types: `beforeSave`/`afterSave`,
|
|
4992
|
+
`beforeDelete`/`afterDelete`, `beforeFind`/`afterFind`, `beforeLogin`/`afterLogin`,
|
|
4993
|
+
`afterLogout`, `beforePasswordResetRequest`, `beforeConnect`,
|
|
4994
|
+
`beforeSubscribe`/`afterEvent`, and file triggers on the `@File` pseudo-class.
|
|
4995
|
+
|
|
4996
|
+
The **authentication** triggers (`beforeLogin`/`afterLogin`/`afterLogout`/
|
|
4997
|
+
`beforePasswordResetRequest`) and **LiveQuery** triggers (`beforeConnect`/
|
|
4998
|
+
`beforeSubscribe`/`afterEvent`) route as first-class shapes — predicates
|
|
4999
|
+
(`before_login?` … `after_event?`, `auth_trigger?`/`live_query_trigger?`), an
|
|
5000
|
+
`event` accessor, and top-level `sessionToken` capture into `payload.session_token`.
|
|
5001
|
+
None of them run ActiveModel `save`/`create`/`destroy` callbacks, even though the
|
|
5002
|
+
auth triggers carry a `_User`/`_Session`. Parse Server **ignores the response body**
|
|
5003
|
+
for all of them, so the only signal that affects the operation is rejection, and
|
|
5004
|
+
only on the `before*` variants: returning `false` (or calling `error!`) from a
|
|
5005
|
+
`before_login`/`before_connect`/`before_subscribe`/`before_password_reset_request`
|
|
5006
|
+
handler denies the operation, while anything else is a success no-op. (LiveQuery
|
|
5007
|
+
triggers are delivered over HTTP only in a co-located single-process setup;
|
|
5008
|
+
`beforeConnect` is effectively in-process-only.)
|
|
5009
|
+
|
|
5010
|
+
Key relationship — **`beforeSave`/`afterSave` carry the create variants**. Parse
|
|
5011
|
+
Server has **no `beforeCreate`/`afterCreate` trigger** (it rejects them). The SDK
|
|
5012
|
+
runs your `before_create`/`after_create` callbacks *inside* the
|
|
5013
|
+
`beforeSave`/`afterSave` handler for new objects, in ActiveModel order
|
|
5014
|
+
(`before_save → before_create`, `after_create → after_save`). So **registering a
|
|
5015
|
+
`beforeSave` webhook enables both `before_save` and `before_create`**, and
|
|
5016
|
+
`afterSave` enables both `after_save` and `after_create`. Requesting a create
|
|
5017
|
+
webhook raises with guidance pointing you at the save trigger.
|
|
5018
|
+
|
|
5019
|
+
> **`after_save` is synchronous and on the critical path.** Parse Server waits
|
|
5020
|
+
> for the webhook to return before completing the client's write — even on
|
|
5021
|
+
> `afterSave`, whose return value is a no-op. Treat `after_save` as a place to
|
|
5022
|
+
> **enqueue** background work, not to run long logic inline, and avoid saving
|
|
5023
|
+
> other objects inside it (each cascading save fires more webhooks). `beforeSave`
|
|
5024
|
+
> can mutate or reject the write, so it is necessarily inline — keep it lean.
|
|
5025
|
+
|
|
5026
|
+
For the full picture — trigger types, registration, the synchronous-latency
|
|
5027
|
+
model, the Ruby-initiated dedup, and inbound replay/freshness protection — see
|
|
5028
|
+
the [Cloud Code Webhooks Guide](docs/webhooks_guide.md) and
|
|
5029
|
+
[`examples/webhook_server.rb`](examples/webhook_server.rb).
|
|
5030
|
+
|
|
4931
5031
|
#### Trigger object state
|
|
4932
5032
|
|
|
4933
5033
|
Because the trigger payload is server-authoritative, the `parse_object` your
|
|
@@ -5764,6 +5864,13 @@ The integration tests use Docker Compose to spin up a Parse Server instance with
|
|
|
5764
5864
|
- Docker and Docker Compose installed
|
|
5765
5865
|
- Ruby environment with bundler
|
|
5766
5866
|
|
|
5867
|
+
> **Always run the suite with `bundle exec`.** Newer `minitest` (6.0+) moved
|
|
5868
|
+
> `minitest/mock` out into a separate gem, so a bare `ruby`/`rake` invocation
|
|
5869
|
+
> activates minitest 6 and then fails to load `minitest/mock`, aborting every
|
|
5870
|
+
> test at load time with `cannot load such file -- minitest/mock (LoadError)`.
|
|
5871
|
+
> Running through bundler pins the locked versions and avoids this. If you hit
|
|
5872
|
+
> that LoadError, prefix the command with `bundle exec`.
|
|
5873
|
+
|
|
5767
5874
|
#### Setup and Running Tests
|
|
5768
5875
|
|
|
5769
5876
|
1. **Enable Docker Tests**: Set the environment variable to enable Docker-based tests:
|
|
@@ -5848,6 +5955,56 @@ docker compose -f scripts/docker/docker-compose.test.yml up -d
|
|
|
5848
5955
|
curl -s http://localhost:29337/parse/health # -> {"status":"ok"}
|
|
5849
5956
|
```
|
|
5850
5957
|
|
|
5958
|
+
#### Network Exposure and the Preflight Guard
|
|
5959
|
+
|
|
5960
|
+
Every service binds to loopback (`127.0.0.1`) by default, and the default
|
|
5961
|
+
credentials above are committed to this repository — safe in combination,
|
|
5962
|
+
since nothing off the host can reach them. Each bind is overridable
|
|
5963
|
+
(`PARSE_BIND`, `MONGO_BIND`, `REDIS_BIND`, `DASHBOARD_BIND`) for the
|
|
5964
|
+
occasional need to attach a remote client while debugging.
|
|
5965
|
+
|
|
5966
|
+
That override is a footgun: pointing a bind at `0.0.0.0` while the default
|
|
5967
|
+
credentials are still in force would publish an admin-credentialed stack
|
|
5968
|
+
(Mongo `admin:password`, master key `psnextItMasterKey`) onto your LAN. A
|
|
5969
|
+
`preflight` service runs before anything else and **refuses to start the
|
|
5970
|
+
stack** in exactly that case. To proceed, do one of:
|
|
5971
|
+
|
|
5972
|
+
```bash
|
|
5973
|
+
# 1. Keep it loopback (the default) — just omit the *_BIND override.
|
|
5974
|
+
|
|
5975
|
+
# 2. Supply real credentials instead of the committed test defaults.
|
|
5976
|
+
PARSE_MASTER_KEY="$(openssl rand -hex 24)" \
|
|
5977
|
+
MONGO_ROOT_PASSWORD="$(openssl rand -hex 24)" \
|
|
5978
|
+
MONGO_BIND=0.0.0.0 \
|
|
5979
|
+
docker compose -f scripts/docker/docker-compose.test.yml up -d
|
|
5980
|
+
|
|
5981
|
+
# 3. Acknowledge the exposure on a trusted, isolated network.
|
|
5982
|
+
ALLOW_INSECURE_BIND=1 MONGO_BIND=0.0.0.0 \
|
|
5983
|
+
docker compose -f scripts/docker/docker-compose.test.yml up -d
|
|
5984
|
+
```
|
|
5985
|
+
|
|
5986
|
+
#### Secret Injection (real credentials)
|
|
5987
|
+
|
|
5988
|
+
The committed defaults are deliberately non-secret, so the loopback stack
|
|
5989
|
+
needs no secrets manager. If you point the stack at *real or shared*
|
|
5990
|
+
credentials (option 2 above, or a staging Mongo), keep them out of your
|
|
5991
|
+
shell history and the compose file by injecting them at launch. The stack
|
|
5992
|
+
reads plain environment variables, so any injector works:
|
|
5993
|
+
|
|
5994
|
+
```bash
|
|
5995
|
+
# 1Password CLI — secrets resolved from an op:// .env reference file.
|
|
5996
|
+
op run --env-file=.env.secrets -- \
|
|
5997
|
+
docker compose -f scripts/docker/docker-compose.test.yml up -d
|
|
5998
|
+
|
|
5999
|
+
# Doppler — secrets pulled from a configured project/config.
|
|
6000
|
+
doppler run -- \
|
|
6001
|
+
docker compose -f scripts/docker/docker-compose.test.yml up -d
|
|
6002
|
+
```
|
|
6003
|
+
|
|
6004
|
+
Use the committed `.env.sample` as the reference for which variables each
|
|
6005
|
+
side expects; copy it to a gitignored `.env` (or an `op://`-referenced
|
|
6006
|
+
`.env.secrets`) and fill in real values there.
|
|
6007
|
+
|
|
5851
6008
|
#### Environment Variables
|
|
5852
6009
|
|
|
5853
6010
|
The defaults above are baked into the Compose file and the test helpers, so the
|
data/Rakefile
CHANGED
|
@@ -77,12 +77,57 @@ def client_console_token!
|
|
|
77
77
|
pwd = $stdin.gets.to_s
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
|
-
u =
|
|
80
|
+
u = console_login_with_optional_mfa(user, pwd.chomp)
|
|
81
81
|
abort "[client:console] login failed for #{user.inspect}" if u.nil? || u.session_token.to_s.empty?
|
|
82
82
|
puts "Logged in as #{u.username} (#{u.id})."
|
|
83
83
|
u.session_token
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# Log `user` in, transparently handling an MFA-enrolled account. If the server
|
|
87
|
+
# reports that additional MFA auth is required, prompt for a TOTP / recovery
|
|
88
|
+
# code (or read +PARSE_LOGIN_MFA+ for non-interactive use) and retry via
|
|
89
|
+
# {Parse::User.login_with_mfa}. Returns a logged-in {Parse::User}, or nil when
|
|
90
|
+
# the credentials themselves are rejected (so the caller's "login failed" abort
|
|
91
|
+
# still fires for a bad password).
|
|
92
|
+
def console_login_with_optional_mfa(user, pwd)
|
|
93
|
+
# Parse Server signals "this account needs an MFA token" two ways depending on
|
|
94
|
+
# the error code path: a returned error response ("Missing additional
|
|
95
|
+
# authData ...") or a raised Parse::Error for the OTHER_CAUSE (code <= 100)
|
|
96
|
+
# variant. Treat both as "prompt for MFA"; anything else is a real credential
|
|
97
|
+
# failure and must NOT trigger an MFA prompt.
|
|
98
|
+
mfa_indicator = /additional\s+authData|missing.*mfa|\bMFA\b/i
|
|
99
|
+
begin
|
|
100
|
+
response = Parse.client.login(user, pwd)
|
|
101
|
+
if response.success?
|
|
102
|
+
return Parse::User.with_authdata_trust { Parse::User.build(response.result) }
|
|
103
|
+
end
|
|
104
|
+
return nil unless response.error.to_s.match?(mfa_indicator)
|
|
105
|
+
rescue Parse::Error, Parse::Client::ResponseError => e
|
|
106
|
+
raise unless e.message.to_s.match?(mfa_indicator)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
token = ENV["PARSE_LOGIN_MFA"].to_s.strip
|
|
110
|
+
if token.empty?
|
|
111
|
+
print "MFA token (authenticator code or recovery code): "
|
|
112
|
+
token = $stdin.gets.to_s.strip
|
|
113
|
+
end
|
|
114
|
+
abort "[client:console] MFA token required for #{user.inspect}" if token.empty?
|
|
115
|
+
|
|
116
|
+
# A wrong/expired token can surface either as Parse::MFA::VerificationError or,
|
|
117
|
+
# depending on the server error code path, as a generic Parse::Error (e.g.
|
|
118
|
+
# ServiceUnavailableError for the OTHER_CAUSE code) or a nil return. Since a
|
|
119
|
+
# token was supplied here, treat any failure as an MFA verification failure
|
|
120
|
+
# and abort cleanly rather than letting an unhandled exception escape.
|
|
121
|
+
result =
|
|
122
|
+
begin
|
|
123
|
+
Parse::User.login_with_mfa(user, pwd, token)
|
|
124
|
+
rescue Parse::MFA::VerificationError, Parse::Error => e
|
|
125
|
+
abort "[client:console] MFA verification failed for #{user.inspect}: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
abort "[client:console] MFA verification failed for #{user.inspect}" if result.nil?
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
86
131
|
# Default test task runs all tests with Docker enabled.
|
|
87
132
|
#
|
|
88
133
|
# `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
|
|
@@ -131,7 +176,11 @@ def run_test_files!(label, files, log:)
|
|
|
131
176
|
puts "[#{n}/#{total}] #{file}"
|
|
132
177
|
puts "=" * 80
|
|
133
178
|
t0 = Time.now
|
|
134
|
-
|
|
179
|
+
# Always go through `bundle exec` so the locked gem versions win. With a
|
|
180
|
+
# bare `ruby`, RubyGems activates the newest installed minitest (6.0.x),
|
|
181
|
+
# which dropped the bundled `minitest/mock`; the standalone `minitest-mock`
|
|
182
|
+
# gem then can't co-activate and `test_helper.rb` fails to load every file.
|
|
183
|
+
ok = system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}")
|
|
135
184
|
dt = Time.now - t0
|
|
136
185
|
results << [file, ok, dt]
|
|
137
186
|
summary = format("[%d/%d] %-4s %7.1fs %s", n, total, ok ? "PASS" : "FAIL", dt, file)
|
|
@@ -203,7 +252,7 @@ namespace :test do
|
|
|
203
252
|
puts "=" * 80
|
|
204
253
|
# Each file runs in its own process so a server outage in one cannot
|
|
205
254
|
# bleed into the next.
|
|
206
|
-
system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || begin
|
|
255
|
+
system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}") || begin
|
|
207
256
|
# A disruptive test may have left the server down on failure; bring
|
|
208
257
|
# it back so a follow-up run / other tasks start from a clean state.
|
|
209
258
|
system("docker start #{ENV["PSNEXT_PREFIX"] || "psnext-it"}-server", out: IO::NULL, err: IO::NULL)
|
|
@@ -372,6 +372,10 @@ embed-time chunking), use one of these patterns:
|
|
|
372
372
|
|
|
373
373
|
## Retrieval (RAG)
|
|
374
374
|
|
|
375
|
+
> For an end-to-end runnable script — managed `embed`, `agent_searchable`,
|
|
376
|
+
> `semantic_search`, and an OpenAI/Anthropic generation add-in — see
|
|
377
|
+
> [`examples/rag_chatbot.rb`](../examples/rag_chatbot.rb).
|
|
378
|
+
|
|
375
379
|
`Parse::Retrieval` (`Parse::RAG` is an alias) sits on top of
|
|
376
380
|
`find_similar`. `Parse::Retrieval.retrieve` embeds a natural-language
|
|
377
381
|
query, runs Atlas `$vectorSearch` through `find_similar` (so ACL/CLP are
|
|
@@ -395,8 +399,88 @@ chunks = Parse::Retrieval.retrieve(
|
|
|
395
399
|
# => Array<Parse::Retrieval::Chunk> — { id, score, content, source, metadata }
|
|
396
400
|
```
|
|
397
401
|
|
|
398
|
-
`
|
|
399
|
-
|
|
402
|
+
`retrieve` also accepts `hybrid:` (fuse a lexical branch with the vector
|
|
403
|
+
branch — see [Hybrid search](#hybrid-search-vector--lexical) below) and
|
|
404
|
+
`rerank:` (reorder retrieved documents with a cross-encoder before
|
|
405
|
+
chunking — see [Reranking](#reranking)). Both were reserved in earlier
|
|
406
|
+
releases and now ship in 5.4.0.
|
|
407
|
+
|
|
408
|
+
### Hybrid search (vector + lexical)
|
|
409
|
+
|
|
410
|
+
`Class.hybrid_search` runs a lexical Atlas Search (`$search`) branch and a
|
|
411
|
+
`$vectorSearch` branch as **two independent aggregations**, then fuses
|
|
412
|
+
their ranked results with reciprocal-rank fusion (RRF). Two aggregations
|
|
413
|
+
(not a single `$facet`) is mandatory: `$vectorSearch` is prohibited inside
|
|
414
|
+
`$facet` / `$lookup` / `$unionWith` and must be stage 0 of its pipeline.
|
|
415
|
+
Each branch enforces ACL/CLP/`protectedFields` independently before
|
|
416
|
+
fusion (via `Parse::AtlasSearch.search` and `Parse::VectorSearch.search`),
|
|
417
|
+
so the fused rows are already access-filtered — there is no separate
|
|
418
|
+
hydration fetch.
|
|
419
|
+
|
|
420
|
+
```ruby
|
|
421
|
+
hits = Article.hybrid_search(
|
|
422
|
+
text: "how do I reset my password", # embedded for the vector branch;
|
|
423
|
+
# also the default lexical query
|
|
424
|
+
lexical: { index: "article_search", fields: %w[title body] },
|
|
425
|
+
vector: { index: "article_embedding_idx", num_candidates: 200 },
|
|
426
|
+
k: 20,
|
|
427
|
+
fusion: { k_constant: 60, weights: { lexical: 0.4, vector: 0.6 } },
|
|
428
|
+
session_token: user.session_token, # ACL scope, applied to BOTH branches
|
|
429
|
+
)
|
|
430
|
+
# => Array<Parse::Object>; each carries #hybrid_score, #hybrid_ranks,
|
|
431
|
+
# and #vector_score / #search_score when that branch contributed.
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**RRF math.** `fused_score(d) = Σ_b weight_b / (k_constant + rank_b(d))`,
|
|
435
|
+
where `rank_b(d)` is the document's 1-based rank in branch `b`. A larger
|
|
436
|
+
`k_constant` (default 60) flattens the contribution curve. `weights`
|
|
437
|
+
defaults to 1.0 per branch. `Parse::VectorSearch::Hybrid.rrf` exposes the
|
|
438
|
+
pure fusion if you want to fuse pre-fetched ranked lists yourself.
|
|
439
|
+
|
|
440
|
+
**Native `$rankFusion` (Atlas 8.0+).**
|
|
441
|
+
`Parse::VectorSearch::Hybrid.rank_fusion_supported?(collection)` detects
|
|
442
|
+
the native server-side fusion stage via a cached behavioural probe (1-hour
|
|
443
|
+
TTL — not version-string parsing). Native execution is **opt-in**
|
|
444
|
+
(`fusion: { method: :rrf_native }`) and falls back to the client-side path
|
|
445
|
+
when the cluster does not support it; the default `:rrf` always fuses
|
|
446
|
+
client-side, which is the fully-enforced, deterministic path. `$rankFusion`
|
|
447
|
+
is admitted to `PipelineSecurity::ALLOWED_STAGES` for the native path.
|
|
448
|
+
|
|
449
|
+
`Parse::Retrieval.retrieve(hybrid: true, ...)` routes through
|
|
450
|
+
`hybrid_search` and chunks the fused results; pass `hybrid: { lexical:,
|
|
451
|
+
vector:, fusion: }` to configure the branches. Tenant scope is folded into
|
|
452
|
+
**both** branches (the vector Atlas pre-filter and the lexical
|
|
453
|
+
post-`$search` `$match`) so neither leaks cross-tenant document existence.
|
|
454
|
+
|
|
455
|
+
### Reranking
|
|
456
|
+
|
|
457
|
+
A reranker reorders retrieved documents by a cross-encoder relevance score
|
|
458
|
+
**before** chunking. Pass any object answering
|
|
459
|
+
`#rerank(query:, documents:, top_n:)` — typically a
|
|
460
|
+
`Parse::Retrieval::Reranker::Base` subclass:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
reranker = Parse::Retrieval::Reranker::Cohere.new(
|
|
464
|
+
api_key: ENV.fetch("COHERE_API_KEY"), model: "rerank-v3.5",
|
|
465
|
+
)
|
|
466
|
+
chunks = Parse::Retrieval.retrieve(
|
|
467
|
+
query: "reset my password", klass: Article, k: 30,
|
|
468
|
+
rerank: reranker, rerank_top_n: 5, # keep the 5 most relevant docs
|
|
469
|
+
)
|
|
470
|
+
# Reranked chunks' score is the cross-encoder relevance_score.
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`Reranker::Fixture` is a deterministic, zero-network reranker (lexical
|
|
474
|
+
token overlap) for tests. The `Reranker::Base` protocol validates inputs,
|
|
475
|
+
bounds `top_n`, rejects out-of-range indices, and sorts descending —
|
|
476
|
+
adapters implement only the network call (`#rerank_scores`).
|
|
477
|
+
|
|
478
|
+
> **Spend cap.** The `semantic_search` agent tool charges the estimated
|
|
479
|
+
> query-embedding tokens against the caller's tenant budget via
|
|
480
|
+
> `Parse::Embeddings::SpendCap` (opt-in; `configure(limit_tokens:,
|
|
481
|
+
> window:)`). A breach hard-refuses (surfaced to the agent as a
|
|
482
|
+
> rate-limited tool error). Admin agents are exempt; direct
|
|
483
|
+
> `find_similar` / `retrieve` callers are not metered.
|
|
400
484
|
|
|
401
485
|
### Chunkers
|
|
402
486
|
|
data/docs/client_sdk_guide.md
CHANGED
|
@@ -11,6 +11,11 @@ go over REST, and authorization is carried by the user's `sessionToken`.
|
|
|
11
11
|
Every claim below is locked in by the integration tests under
|
|
12
12
|
`test/lib/parse/client_*_integration_test.rb`.
|
|
13
13
|
|
|
14
|
+
For a runnable starting point, see
|
|
15
|
+
[`examples/basic_client.rb`](../examples/basic_client.rb) (a no-master client
|
|
16
|
+
with a row-level ACL-enforcement demo) and its master-key counterpart
|
|
17
|
+
[`examples/basic_server.rb`](../examples/basic_server.rb).
|
|
18
|
+
|
|
14
19
|
---
|
|
15
20
|
|
|
16
21
|
## Why a separate guide?
|
data/docs/mcp_guide.md
CHANGED
|
@@ -7,7 +7,7 @@ The Model Context Protocol (MCP) is a standardized JSON-RPC 2.0-based interface
|
|
|
7
7
|
Three deployment modes are available:
|
|
8
8
|
|
|
9
9
|
- **Standalone HTTP server (`MCPServer`)** — a WEBrick process for dedicated MCP deployments.
|
|
10
|
-
- **Rack-mountable adapter (`MCPRackApp`)** — embeds inside an existing Sinatra or Rails application.
|
|
10
|
+
- **Rack-mountable adapter (`MCPRackApp`)** — embeds inside an existing Sinatra or Rails application. This is the primary deployment for the MCP 2025-06-18 Streamable HTTP transport; enable it with `transport: :streamable_http` (see [Streamable HTTP transport](#streamable-http-transport-primary)).
|
|
11
11
|
- **Direct in-process dispatcher (`MCPDispatcher`)** — a pure function for in-process usage, custom transports, and testing.
|
|
12
12
|
|
|
13
13
|
---
|
|
@@ -191,6 +191,42 @@ map("/mcp") { run mcp_app }
|
|
|
191
191
|
map("/") { run ->(env) { [200, {"Content-Type" => "text/plain"}, ["ok"]] } }
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
#### Streamable HTTP transport (primary)
|
|
195
|
+
|
|
196
|
+
The MCP 2025-06-18 **Streamable HTTP** transport is the recommended transport for `MCPRackApp`. It is a single connection model in which the client `POST`s JSON-RPC requests (receiving either a buffered JSON reply or, with `Accept: text/event-stream`, a streamed SSE reply) and holds open a long-lived `GET` request to receive server-initiated notifications. Session termination is signalled with `DELETE` carrying the `Mcp-Session-Id`.
|
|
197
|
+
|
|
198
|
+
Enable the whole transport with one switch:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
mcp_app = Parse::Agent.rack_app(transport: :streamable_http) do |env|
|
|
202
|
+
# ... auth factory ...
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`transport: :streamable_http` is exactly equivalent to `streaming: true, notifications: true` — it turns on POST→SSE streaming and the server→client `GET /` notification stream together. Add `resource_subscriptions: true` alongside it to upgrade the server→client bus from the plain notification posture to the LiveQuery-backed resource-subscription posture:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
mcp_app = Parse::Agent.rack_app(
|
|
210
|
+
transport: :streamable_http,
|
|
211
|
+
resource_subscriptions: true, # optional: bridge LiveQuery resource updates
|
|
212
|
+
) do |env|
|
|
213
|
+
# ...
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`transport:` is a closed enum:
|
|
218
|
+
|
|
219
|
+
| Value | Effect |
|
|
220
|
+
|-------|--------|
|
|
221
|
+
| `:streamable_http` | Full Streamable HTTP transport (`streaming: true` + `notifications: true`). |
|
|
222
|
+
| `:legacy` / `nil` (default) | Historical behavior: buffered JSON responses, no server→client stream. The standalone SSE/JSON path below remains a supported fallback. |
|
|
223
|
+
|
|
224
|
+
Passing `transport: :streamable_http` together with an explicit `streaming:` or `notifications:` raises `ArgumentError` (the switch already owns those toggles); any value other than the two above also raises. The default is unchanged, so an existing `Parse::Agent.rack_app { ... }` keeps its non-streaming JSON behavior until you opt in.
|
|
225
|
+
|
|
226
|
+
**WEBrick cannot deliver Streamable HTTP.** The switch — like `streaming:` — has no effect under the WEBrick-backed standalone `MCPServer`, which buffers responses and cannot hold the `GET` stream open. Use Puma, Falcon, or Unicorn for a real Streamable HTTP deployment.
|
|
227
|
+
|
|
228
|
+
The remaining subsections document the individual toggles `transport: :streamable_http` consolidates, for operators who need finer control or are reading older configurations.
|
|
229
|
+
|
|
194
230
|
#### MCP progress notifications via SSE (opt-in)
|
|
195
231
|
|
|
196
232
|
**WEBrick cannot stream.** The standalone `MCPServer` is WEBrick-based and buffers the full response before sending. Setting `streaming: true` on an `MCPRackApp` mounted under WEBrick silently degrades to a single buffered response with concatenated SSE events. SSE streaming requires a Rack server that supports streaming response bodies — **Puma, Falcon, or Unicorn**. Verify your deployment uses one of these before relying on `streaming: true`.
|
|
@@ -537,10 +573,29 @@ Parse Server version and its `masterKeyIps` configuration.)
|
|
|
537
573
|
soft cap *equal to* `max_concurrent_dispatchers`. So the effective steady-state
|
|
538
574
|
ceiling across both surfaces is up to **2× `max_concurrent_dispatchers`** (up
|
|
539
575
|
to N request-scoped SSE dispatchers plus N listening streams). Size the value
|
|
540
|
-
with that 2× factor in mind (e.g. relative to your Puma `max_threads`).
|
|
541
|
-
|
|
576
|
+
with that 2× factor in mind (e.g. relative to your Puma `max_threads`).
|
|
577
|
+
`max_concurrent_dispatchers:` defaults to a finite **100**
|
|
578
|
+
(`Parse::Agent::MCPRackApp::DEFAULT_MAX_CONCURRENT_DISPATCHERS`), so a
|
|
579
|
+
streaming surface is bounded out of the box — once the cap is reached a new
|
|
580
|
+
SSE request or listening stream is refused with a `503` JSON-RPC `-32000`
|
|
581
|
+
("server busy"). Pass an explicit positive integer to resize it, or
|
|
582
|
+
`max_concurrent_dispatchers: nil` to knowingly run uncapped (the app logs a
|
|
542
583
|
one-time warning at construction when a streaming or subscription/notification
|
|
543
|
-
surface is enabled
|
|
584
|
+
surface is enabled with `nil`). A non-positive or non-integer value raises
|
|
585
|
+
`ArgumentError`.
|
|
586
|
+
- **Client disconnect mid-tool-call.** When a client drops the connection while
|
|
587
|
+
a tool is still running, the SSE worker is torn down and the dispatcher's
|
|
588
|
+
cancellation token is tripped, so a cooperative tool (one that checks
|
|
589
|
+
`agent.cancelled?` at a checkpoint) exits promptly. A tool blocked inside a
|
|
590
|
+
Mongo/REST roundtrip cannot observe the token, but its slot is reclaimed when
|
|
591
|
+
the per-tool `Timeout` or the clean MongoDB `socket_timeout` (10s) / REST
|
|
592
|
+
`timeout` (30s) deadline fires — through the driver's clean error path. The
|
|
593
|
+
orphaned dispatcher is **intentionally not force-killed**: a `Thread#kill`
|
|
594
|
+
would bypass the driver's connection-invalidation and could return a half-used
|
|
595
|
+
pooled connection to a later request. To observe how often disconnects abandon
|
|
596
|
+
in-flight work, watch the cumulative
|
|
597
|
+
`Parse::Agent::MCPRackApp.abandoned_dispatcher_count` or subscribe to the
|
|
598
|
+
`parse.agent.mcp_dispatcher_abandoned` `ActiveSupport::Notifications` event.
|
|
544
599
|
|
|
545
600
|
### Listening-stream ownership
|
|
546
601
|
|
|
@@ -173,6 +173,58 @@ set the same kwargs on the query for chainable composition.
|
|
|
173
173
|
Related: `first_direct(n)` for the first N rows, `count_direct` for a
|
|
174
174
|
count-only query. Both accept the same auth kwargs.
|
|
175
175
|
|
|
176
|
+
#### Field projection: `keys` and `exclude_keys`
|
|
177
|
+
|
|
178
|
+
The two field-selection options behave differently on the direct path
|
|
179
|
+
because MongoDB's `$project` is an allowlist, not a denylist:
|
|
180
|
+
|
|
181
|
+
- **`keys` (allowlist)** compiles to a `$project` stage in the direct
|
|
182
|
+
pipeline, so the projection runs server-side in MongoDB — only the
|
|
183
|
+
named fields (plus the reserved envelope: `_id`, `_created_at`,
|
|
184
|
+
`_updated_at`, `_acl`) leave the database.
|
|
185
|
+
|
|
186
|
+
- **`exclude_keys` (denylist)** has no `$project` equivalent, so Parse
|
|
187
|
+
Stack honors it as a **post-fetch sanitize**: the pipeline is
|
|
188
|
+
unchanged, and the SDK recursively strips every key with a matching
|
|
189
|
+
name from the decoded results in Ruby. The fields still travel from
|
|
190
|
+
MongoDB to the client — this is a result-shaping convenience, not a
|
|
191
|
+
data-minimization or access-control boundary.
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Allowlist — projected server-side via $project
|
|
195
|
+
Song.query.keys(:title, :artist).results_direct
|
|
196
|
+
|
|
197
|
+
# Denylist — stripped client-side after fetch
|
|
198
|
+
Song.query.exclude_keys(:internal_notes).results_direct
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Two consequences specific to the direct path:
|
|
202
|
+
|
|
203
|
+
1. **Recursive by name.** `exclude_keys(:name)` removes `name` at every
|
|
204
|
+
depth, including inside included/nested objects — so a query that
|
|
205
|
+
includes a pointer also strips the pointed-to object's `name`. This
|
|
206
|
+
is broader than Parse Server's REST `excludeKeys`, which is
|
|
207
|
+
path-scoped (top-level or dotted) and would leave the nested field
|
|
208
|
+
intact. The same query can therefore return different shapes on the
|
|
209
|
+
REST and direct paths.
|
|
210
|
+
|
|
211
|
+
2. **Reserved fields are never stripped.** `objectId`, `className`,
|
|
212
|
+
`__type`, `createdAt`, `updatedAt`, `ACL`, and their Mongo
|
|
213
|
+
storage-form names (`_id`, `_created_at`, `_updated_at`, `_acl`) are
|
|
214
|
+
always retained, so excluding one of them is a no-op rather than a
|
|
215
|
+
way to break object reconstruction.
|
|
216
|
+
|
|
217
|
+
The sanitize applies to the object/decoded result paths
|
|
218
|
+
(`results_direct`, `first_direct`, and the auto-promoted
|
|
219
|
+
`$inQuery`/`$notInQuery` aggregation). The raw aggregation accessor
|
|
220
|
+
(`aggregate(...).raw`) returns documents untouched.
|
|
221
|
+
|
|
222
|
+
Because `exclude_keys` here is a projection convenience and not an
|
|
223
|
+
ACL/CLP/`protectedFields` boundary, the security contract in
|
|
224
|
+
[Security](#security) is unaffected — to keep a field from leaving the
|
|
225
|
+
database, use `keys` (allowlist) or `protectedFields`, not
|
|
226
|
+
`exclude_keys`.
|
|
227
|
+
|
|
176
228
|
### `Query#aggregate(pipeline, mongo_direct: true)`
|
|
177
229
|
|
|
178
230
|
```ruby
|
|
@@ -233,9 +285,35 @@ raw = Parse::MongoDB.find(
|
|
|
233
285
|
```
|
|
234
286
|
|
|
235
287
|
Convenience wrapper around `db.find`. Accepts `limit:`, `skip:`, `sort:`,
|
|
236
|
-
`projection:`, `max_time_ms:`. When `:limit` is omitted the call applies
|
|
288
|
+
`projection:`, `hint:`, `max_time_ms:`. When `:limit` is omitted the call applies
|
|
237
289
|
`DEFAULT_FIND_LIMIT = 1000` and warns; pass `limit: 0` to opt out.
|
|
238
290
|
|
|
291
|
+
### Forcing an index with `hint`
|
|
292
|
+
|
|
293
|
+
When the query planner picks a sub-optimal index on a large collection,
|
|
294
|
+
`Query#hint` forces a specific one. It applies on **both** paths — the REST body
|
|
295
|
+
(`hint` parameter, Parse Server 7.4.0+) and the mongo-direct path — so a plan you
|
|
296
|
+
diagnosed with `Query#explain` can be corrected without dropping to `mongosh`.
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
# Diagnose, then force the index, on the mongo-direct path:
|
|
300
|
+
Post.query(:status => "published").order(:created_at.desc).hint("status_1_created_at_-1")
|
|
301
|
+
.results_direct
|
|
302
|
+
|
|
303
|
+
# A key pattern works too:
|
|
304
|
+
Post.query(:status => "published").hint({ "status" => 1, "createdAt" => -1 }).count_direct
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
On the mongo-direct path the hint is forwarded to the driver as the Mongo `hint`
|
|
308
|
+
option: `results_direct` / `count_direct` / `distinct_direct` pass it to
|
|
309
|
+
`Parse::MongoDB.aggregate` (`hint:` → the aggregation `hint` option), and the
|
|
310
|
+
primitives `Parse::MongoDB.aggregate(..., hint:)` and
|
|
311
|
+
`Parse::MongoDB.find(..., hint:)` accept it directly. The index name (a `String`)
|
|
312
|
+
or a key pattern (`Hash`) are both accepted; an unknown index name is rejected by
|
|
313
|
+
MongoDB, which is the intended fail-fast signal that the hint is stale.
|
|
314
|
+
|
|
315
|
+
`hint` is unset by default (the planner chooses); it is purely an override.
|
|
316
|
+
|
|
239
317
|
### Geo queries
|
|
240
318
|
|
|
241
319
|
Three geo query constraints land in v4.4.0 alongside a direct
|
|
@@ -620,6 +698,20 @@ ACL/CLP enforcement if the SDK applies it.
|
|
|
620
698
|
As of **v4.4.0**, the SDK applies that enforcement on the mongo-direct
|
|
621
699
|
path when the caller supplies a scope. Five layers compose:
|
|
622
700
|
|
|
701
|
+
> **Atlas index entry points share this enforcement.** The Atlas-index
|
|
702
|
+
> stages (`$vectorSearch`, `$search`, `$rankFusion`) must be stage 0 of
|
|
703
|
+
> their pipeline, so they cannot route through `Parse::MongoDB.aggregate`
|
|
704
|
+
> (which prepends an ACL `$match` at stage 0). `Parse::VectorSearch.search`
|
|
705
|
+
> (`find_similar`), `Parse::AtlasSearch.search`, and
|
|
706
|
+
> `Parse::VectorSearch::Hybrid` (`Class.hybrid_search`, v5.4.0) therefore
|
|
707
|
+
> reproduce the same enforcement chain **inline** — the ACL `_rperm`
|
|
708
|
+
> `$match` is appended AFTER the index stage, and CLP / `protectedFields` /
|
|
709
|
+
> the internal-fields denylist run post-fetch — so the same scope kwargs
|
|
710
|
+
> (`session_token:` / `acl_user:` / `acl_role:` / `master:`) and the same
|
|
711
|
+
> contract apply. Hybrid search fuses two independently-enforced branches,
|
|
712
|
+
> so fused rows are already access-filtered. `$rankFusion` was added to the
|
|
713
|
+
> strict-mode allowlist (Layer 1) in v5.4.0 for the opt-in native path.
|
|
714
|
+
|
|
623
715
|
### Layer 1: Pipeline-security denylist (always on)
|
|
624
716
|
|
|
625
717
|
`Parse::PipelineSecurity` refuses dangerous operators at any depth in
|
data/docs/usage_guide.md
CHANGED
|
@@ -83,10 +83,20 @@ Song.query.order(:plays.desc).skip(10).limit(20).results
|
|
|
83
83
|
# Include related objects
|
|
84
84
|
Song.all(includes: [:album, :comments])
|
|
85
85
|
|
|
86
|
-
# Select specific fields
|
|
86
|
+
# Select specific fields (allowlist)
|
|
87
87
|
Song.all(keys: [:title, :artist])
|
|
88
|
+
|
|
89
|
+
# Omit specific fields (denylist)
|
|
90
|
+
Song.query.exclude_keys(:internal_notes).results
|
|
88
91
|
```
|
|
89
92
|
|
|
93
|
+
> On the mongo-direct read path, `keys` is projected server-side while
|
|
94
|
+
> `exclude_keys` is applied as a recursive post-fetch sanitize (it strips
|
|
95
|
+
> matching field names at every depth and never removes reserved fields
|
|
96
|
+
> such as `objectId`). See the
|
|
97
|
+
> [Direct MongoDB Integration Guide](mongodb_direct_guide.md) for the
|
|
98
|
+
> exact semantics and how it differs from the REST path.
|
|
99
|
+
|
|
90
100
|
## Aggregation
|
|
91
101
|
|
|
92
102
|
```ruby
|