parse-stack-next 5.1.1 → 5.2.1
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/.env.sample +12 -0
- data/.env.test +4 -4
- data/CHANGELOG.md +630 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +6 -1
- data/README.md +226 -39
- data/Rakefile +56 -10
- data/docs/atlas_vector_search_guide.md +110 -9
- data/docs/mcp_guide.md +504 -0
- data/docs/mongodb_direct_guide.md +66 -1
- data/docs/mongodb_index_optimization_guide.md +22 -1
- data/docs/usage_guide.md +15 -0
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +90 -19
- data/lib/parse/agent/describe.rb +1 -0
- data/lib/parse/agent/errors.rb +16 -0
- data/lib/parse/agent/mcp_client.rb +9 -0
- data/lib/parse/agent/mcp_dispatcher.rb +139 -7
- data/lib/parse/agent/mcp_rack_app.rb +621 -17
- data/lib/parse/agent/mcp_subscriptions.rb +607 -0
- data/lib/parse/agent/metadata_dsl.rb +58 -0
- data/lib/parse/agent/metadata_registry.rb +141 -1
- data/lib/parse/agent/prompt_hardening.rb +213 -0
- data/lib/parse/agent/result_formatter.rb +18 -3
- data/lib/parse/agent/tools.rb +167 -24
- data/lib/parse/agent.rb +692 -21
- data/lib/parse/client/request.rb +55 -4
- data/lib/parse/client/response.rb +4 -0
- data/lib/parse/client.rb +205 -7
- data/lib/parse/model/classes/installation.rb +27 -10
- data/lib/parse/model/classes/user.rb +8 -0
- data/lib/parse/model/core/actions.rb +65 -13
- data/lib/parse/model/core/embed_managed.rb +19 -14
- data/lib/parse/model/core/indexing.rb +108 -16
- data/lib/parse/model/core/querying.rb +29 -0
- data/lib/parse/model/model.rb +34 -3
- data/lib/parse/model/object.rb +42 -0
- data/lib/parse/query.rb +90 -24
- data/lib/parse/retrieval/agent_tool.rb +369 -0
- data/lib/parse/retrieval/chunk.rb +74 -0
- data/lib/parse/retrieval/chunker.rb +208 -0
- data/lib/parse/retrieval/retriever.rb +274 -0
- data/lib/parse/retrieval.rb +10 -0
- data/lib/parse/schema.rb +69 -20
- data/lib/parse/stack/version.rb +2 -2
- data/lib/parse/webhooks/payload.rb +62 -34
- data/lib/parse/webhooks.rb +15 -3
- data/parse-stack-next.gemspec +1 -1
- data/scripts/docker/docker-compose.atlas.yml +14 -10
- data/scripts/docker/docker-compose.test.yml +24 -20
- data/scripts/docker/mongo-init.js +3 -3
- data/scripts/start-parse.sh +10 -0
- data/scripts/start_mcp_server.rb +1 -1
- data/scripts/test_server_connection.rb +1 -1
- data/scripts/vector_prototype/create_vector_index.js +1 -1
- data/scripts/vector_prototype/fetch_embeddings.py +2 -2
- data/scripts/vector_prototype/query_prototype.rb +1 -1
- data/scripts/vector_prototype/run.sh +4 -4
- metadata +10 -2
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
parse-stack-next (5.
|
|
4
|
+
parse-stack-next (5.2.1)
|
|
5
5
|
activemodel (>= 6.1, < 9)
|
|
6
6
|
activesupport (>= 6.1, < 9)
|
|
7
7
|
connection_pool (>= 2.2, < 4)
|
|
@@ -36,6 +36,9 @@ GEM
|
|
|
36
36
|
bigdecimal (4.1.2)
|
|
37
37
|
bson (5.2.0)
|
|
38
38
|
builder (3.3.0)
|
|
39
|
+
bundler-audit (0.9.3)
|
|
40
|
+
bundler (>= 1.2.0)
|
|
41
|
+
thor (~> 1.0)
|
|
39
42
|
coderay (1.1.3)
|
|
40
43
|
concurrent-ruby (1.3.6)
|
|
41
44
|
connection_pool (3.0.2)
|
|
@@ -141,6 +144,7 @@ GEM
|
|
|
141
144
|
rack-session (>= 2.0.0, < 3)
|
|
142
145
|
tilt (~> 2.0)
|
|
143
146
|
stringio (3.2.0)
|
|
147
|
+
thor (1.5.0)
|
|
144
148
|
tilt (2.7.0)
|
|
145
149
|
tsort (0.2.0)
|
|
146
150
|
tzinfo (2.0.6)
|
|
@@ -161,6 +165,7 @@ PLATFORMS
|
|
|
161
165
|
x86_64-linux-musl
|
|
162
166
|
|
|
163
167
|
DEPENDENCIES
|
|
168
|
+
bundler-audit (>= 0.9)
|
|
164
169
|
debug (>= 1.0)
|
|
165
170
|
dotenv
|
|
166
171
|
graphql (~> 2.0)
|
data/README.md
CHANGED
|
@@ -4,6 +4,20 @@
|
|
|
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.2
|
|
8
|
+
|
|
9
|
+
- **5.2.1 — Webhook triggers receive the full Parse object** — trigger handlers (`beforeSave`/`afterSave`/…) now get the complete server object (`createdAt`/`updatedAt`, `ACL`, internal fields); only live credentials (session tokens, password hashes) are stripped. `Parse::Object#existed?` / `#new?` are reliable in `afterSave`, `afterSave` updates carry dirty tracking, and the model lifecycle runs in ActiveModel order — `before_save → before_create` then `after_create → after_save` — so `before_create` now fires for REST/JS/Auth0 creates (and `after_save` no longer double-fires). See [Cloud Code Triggers](#cloud-code-triggers)
|
|
10
|
+
- **Retrieval layer — `Parse::Retrieval` (`Parse::RAG`)** — `Parse::Retrieval.retrieve(query:, klass:, k:, filter:, tenant_scope:, …)` embeds a natural-language query, runs Atlas `$vectorSearch` through the existing ACL-enforcing `find_similar`, and splits each retrieved document's text field into scored `Parse::Retrieval::Chunk`s. Chunking is presentation-only (embedding stays one-vector-per-record), via `Parse::Retrieval::Chunker::FixedSizeOverlap(size:, overlap:, by:, max_chunks_per_document:)` (subclass `Chunker::Base` for custom strategies). ACL is mongo-direct (no REST two-stage); tenant scope folds into the Atlas pre-filter
|
|
11
|
+
- **`semantic_search` agent tool + `agent_searchable`** — declare `agent_searchable field:, filter_fields:` on a model to expose it to the readonly, client-safe `semantic_search` tool. The handler enforces the full agent envelope: searchable-class allowlist, recursive underscore-key refusal + filter-field allowlist on input, `field_allowlist` projection plus tenant-scope re-assertion on output, and score quantization in non-admin contexts
|
|
12
|
+
- **MCP elicitation — human-in-the-loop approval** — opt in with `Parse::Agent.require_approval_for = [:write, :admin]` to require spec-native `elicitation/create` approval before destructive tool calls. A pluggable `agent.approval_gate` (reachable on the non-MCP path too) shows the dry-run diff and blocks on the client's reply; `call_method` resolves the *effective* tier from the target `agent_method`. Fails closed (no capability / no listening stream / non-streaming transport / timeout → refuse); replies are session-bound
|
|
13
|
+
- **Agent impersonation** — `Parse::Agent.new(impersonate_user:, impersonate_mint:, impersonation_label:)` / `agent.impersonate(user)` resolve a real session token for a `_User` (reuse an active `_Session`, or mint a restricted one) and bind it as if `session_token:` had been passed. Master-key-required, fail-closed, with an audit label on `parse.agent.tool_call`
|
|
14
|
+
- **`Parse::Agent::PromptHardening`** — schema-string sanitization (drops non-identifier field names, strips control/zero-width chars, marker-wraps descriptions) on `get_schema`/`get_all_schemas`; embedded-marker scrubbing of untrusted tool content (`prompt_marker_strict` to refuse); operator canary phrases (`prompt_injection_canaries` + `parse.agent.prompt_injection_detected`, `canary_action = :refuse`); `Parse::Agent::PROMPT_VERSION` via `agent.describe[:prompt][:version]`; and a one-time warning when `allowed_llm_endpoints` is unrestricted
|
|
15
|
+
- **Agent telemetry + provenance** — embedding cost on `parse.agent.tool_call` (`embed_calls` / `embed_tokens` / `embed_cost_usd` via `Parse::Agent.embed_cost_per_million_tokens`); optional per-row `_source` citations (`{ class, tool, object_id }`) on read-tool results via `Parse::Agent.include_source_provenance`
|
|
16
|
+
- **General-purpose server-initiated notifications** — `Parse::Agent::MCPRackApp.new(notifications: true)` opens the GET listening-stream bus without LiveQuery resource subscriptions; `MCPRackApp#notify(session_id, method:, params:)` pushes arbitrary `notifications/*` to a session
|
|
17
|
+
- **Token economy** — `Parse::Agent.new(tools: :lean)` narrows the readonly surface to six core tools (~7.9K → ~2.6K `tools/list` tokens); read tools strip the raw `ACL` map and `get_objects`/Atlas tools share `query_class`'s compact normalization; `semantic_search` hoists each chunk's parent into a `documents` map (sent once, not per chunk) and enforces a `max_total_tokens:` budget (default 20K) with a `budget_truncated` signal; a failing `tools/call` forwards `error_code` / `retry_after` / `details` under MCP `_meta`; `get_schema` suggests near-match class names on a typo; `Parse::Agent.measure_embeddings { … }` scopes ingestion embedding cost. See [`docs/mcp_guide.md`](./docs/mcp_guide.md#token-economy)
|
|
18
|
+
|
|
19
|
+
See [CHANGELOG.md](./CHANGELOG.md) for the full 5.2 entry.
|
|
20
|
+
|
|
7
21
|
### What's new in 5.1
|
|
8
22
|
|
|
9
23
|
- **`Parse::File` URL normalization + presigned-URL stash** — `Parse::File#url=` and `attributes=` now strip signed-URL query parameters (`X-Amz-Signature`, `AWSAccessKeyId`, `Key-Pair-Id`, etc.) before storage; the bare canonical URL lands in `@url`, and the original signed URL is stashed in `file.presigned_url` with a data-driven expiry in `file.presigned_url_expires_at`. New `file.presigned_url_valid?(buffer: 60)` predicate, configurable `Parse::File.signed_url_policy = :strip | :raise`, and `Parse::File.log_filter` / `log_filter_strict` regexes for `lograge` / Sentry / Honeybadger scrubbers. `Parse::File#inspect` no longer emits the URL — see CHANGELOG for the error-reporter payload migration callout
|
|
@@ -15,6 +29,7 @@ A full-featured Ruby client SDK for [Parse Server](http://parseplatform.org/). [
|
|
|
15
29
|
- **`Parse::Installation` `belongs_to :user`** — read `installation.user` to find which user a device is currently signed in as. Symmetric `Parse::User#has_many :installations` for targeted-push grouping (master-key-only by Parse Server design; see the YARD for the owner-identity caveat)
|
|
16
30
|
- **`Parse.setup` / `live_query_url:` fixes** — `Parse.setup` is no longer a silent no-op on re-invocation; `Parse.setup(live_query_url: …)` and `live_query: { … }` options no longer raise `ArgumentError`; `ws://` against non-loopback hosts is refused unless `live_query: { allow_insecure: true }` is also passed
|
|
17
31
|
- **MCP `structuredContent` for 5 more tools** — `aggregate`, `export_data`, `atlas_text_search`, `atlas_autocomplete`, `atlas_faceted_search` now emit `structuredContent` with declared `outputSchema`s (sixteen of the built-in catalog now structured)
|
|
32
|
+
- **MCP resource subscriptions (LiveQuery bridge)** — opt-in `Parse::Agent::MCPRackApp.new(resource_subscriptions: true)` serves `resources/subscribe` and pushes `notifications/resources/updated` over a long-lived `GET` listening stream, backed by Parse LiveQuery. Subscribing to a class's `count` / `samples` resource opens a debounced LiveQuery subscription; the `resources.subscribe` capability is advertised only when LiveQuery is enabled and available. Credential-scoped per agent — session-token agents see only readable rows, master-key agents use a dedicated admin connection, and `acl_user:` / `acl_role:` agents are refused (no LiveQuery equivalent). See [`docs/mcp_guide.md`](./docs/mcp_guide.md#resource-subscriptions-livequery-bridge)
|
|
18
33
|
- **New ACL / CLP / `protectedFields` guide** — [`docs/acl_clp_guide.md`](./docs/acl_clp_guide.md) is the canonical reference for the five enforcement layers, the system-class CLP matrix (including the hardcoded master-key-only classes), the `_User` field-visibility recipe, role hierarchy direction, and the REST-aggregate vs `Parse::MongoDB.aggregate` enforcement asymmetry
|
|
19
34
|
|
|
20
35
|
See [CHANGELOG.md](./CHANGELOG.md) for the full 5.1 entry, including breaking changes, migration callouts, and the round-by-round security review notes.
|
|
@@ -383,6 +398,7 @@ The 1.x line is the original [`modernistik/parse-stack`](https://github.com/mode
|
|
|
383
398
|
- [Cloud Code Webhooks](#cloud-code-webhooks)
|
|
384
399
|
- [Cloud Code Functions](#cloud-code-functions)
|
|
385
400
|
- [Cloud Code Triggers](#cloud-code-triggers)
|
|
401
|
+
- [Trigger object state](#trigger-object-state)
|
|
386
402
|
- [Mounting Webhooks Application](#mounting-webhooks-application)
|
|
387
403
|
- [Register Webhooks](#register-webhooks)
|
|
388
404
|
- [Parse REST API Client](#parse-rest-api-client)
|
|
@@ -1563,8 +1579,8 @@ user.mfa_status # => :enabled, :disabled, or :unknown
|
|
|
1563
1579
|
# Disable MFA (requires current token)
|
|
1564
1580
|
user.disable_mfa!(current_token: "123456")
|
|
1565
1581
|
|
|
1566
|
-
# Admin reset (
|
|
1567
|
-
user.
|
|
1582
|
+
# Admin reset (master key) — authorized_by must be a Parse::User
|
|
1583
|
+
user.disable_mfa_master_key!(authorized_by: admin_user)
|
|
1568
1584
|
```
|
|
1569
1585
|
|
|
1570
1586
|
**SMS MFA (requires Parse Server SMS callback):**
|
|
@@ -1873,6 +1889,17 @@ band.drummer # Artist object
|
|
|
1873
1889
|
###### `:field`
|
|
1874
1890
|
This option allows you to set the name of the remote Parse column for this property. Using this will explicitly set the remote property name to the value of this option. The value provided for this option will affect the name of the alias method that is generated when `alias` option is used. **By default, the name of the remote column is the lower-first camel case version of the property name. As an example, for a property with key `:my_property_name`, the framework will implicitly assume that the remote column is `myPropertyName`.**
|
|
1875
1891
|
|
|
1892
|
+
> **Pairing `belongs_to`/`has_many` when you override `:as` or `:field`.** A
|
|
1893
|
+
> `belongs_to`'s storage column comes from its **key** (or its explicit
|
|
1894
|
+
> `:field`), *not* from the class chosen by `:as`. A `has_many` on the inverse
|
|
1895
|
+
> side independently derives the column it queries from the **owning class
|
|
1896
|
+
> name**. These two defaults only line up automatically when you don't override
|
|
1897
|
+
> them — so if you customize one side, set `has_many ..., field:` to the exact
|
|
1898
|
+
> column the `belongs_to` writes, or the `has_many` query silently returns zero
|
|
1899
|
+
> results (it queries a column that does not exist, with no error). For example,
|
|
1900
|
+
> if `Post belongs_to :author, as: :workspace` (stored in column `author`), the
|
|
1901
|
+
> inverse must be `Workspace has_many :posts, as: :post, field: :author`.
|
|
1902
|
+
|
|
1876
1903
|
#### [Has One](https://neurosynq.github.io/parse-stack-next/Parse/Associations/HasOne.html)
|
|
1877
1904
|
The `has_one` creates a one-to-one association with another Parse class. This association says that the other class in the association contains a foreign pointer column which references instances of this class. If your model contains a column that is a Parse pointer to another class, you should use `belongs_to` for that association instead.
|
|
1878
1905
|
|
|
@@ -2307,7 +2334,16 @@ User.first_or_create!({ email: e }, {}, synchronize: false)
|
|
|
2307
2334
|
Parse.synchronize_classes = [User, Device, Subscription]
|
|
2308
2335
|
```
|
|
2309
2336
|
|
|
2310
|
-
The lock is a *latency optimization*; the durable correctness floor is a MongoDB unique index on the dedup tuple
|
|
2337
|
+
The lock is a *latency optimization*; the durable correctness floor is a MongoDB unique index on the dedup tuple, declared on the model with `unique_index_on`:
|
|
2338
|
+
|
|
2339
|
+
```ruby
|
|
2340
|
+
class User < Parse::Object
|
|
2341
|
+
property :email, :string
|
|
2342
|
+
unique_index_on :email # provisioned via User.apply_indexes!
|
|
2343
|
+
end
|
|
2344
|
+
```
|
|
2345
|
+
|
|
2346
|
+
When such an index exists, the synchronize wrapper rescues Parse code 137 (DuplicateValue) and re-queries inside the held lock to return the winner. On a process-local Moneta store (no Redis), the lock degrades to a per-key `Mutex` and emits a `[Parse::CreateLock]` warning. Configure `Parse.synchronize_create_secret` (or `ENV["PARSE_STACK_LOCK_SECRET"]`) to HMAC the lock keys against `query_attrs` content exposure via Redis MONITOR / snapshots.
|
|
2311
2347
|
|
|
2312
2348
|
### Saving
|
|
2313
2349
|
To commit a new record or changes to an existing record to Parse, use the `#save` method. The method will automatically detect whether it is a new object or an existing one and call the appropriate workflow. The use of ActiveModel dirty tracking allows us to send only the changes that were made to the object when saving. **Saving a record will take care of both saving all the changed properties, and associations. However, any modified linked objects (ex. belongs_to) need to be saved independently.**
|
|
@@ -2338,6 +2374,8 @@ To commit a new record or changes to an existing record to Parse, use the `#save
|
|
|
2338
2374
|
|
|
2339
2375
|
The save operation can handle both creating and updating existing objects. If you do not want to update the association data of a changed object, you may use the `#update` method to only save the changed property values. In the case where you want to force update an object even though it has not changed, to possibly trigger your `before_save` hooks, you can use the `#update!` method. In addition, just like with other ActiveModel objects, you may call `reload!` to fetch the current record again from the data store.
|
|
2340
2376
|
|
|
2377
|
+
> **Note:** because of dirty tracking, `#save` is a no-op when the object has no changed fields — it returns `true` **without** issuing a request. A `true` return therefore does not guarantee a server write occurred (assigning a property its current value leaves the object unchanged). To force callbacks and a write even when nothing changed, pass `save(force: true)` or use `#update!`.
|
|
2378
|
+
|
|
2341
2379
|
### Saving applying User ACLs
|
|
2342
2380
|
You may save and delete objects from Parse on behalf of a logged in user by passing the session token to the call to `save` or `destroy`. Doing so will allow Parse to apply the ACLs of this user against the record to see if the user is authorized to read or write the record. See [Parse::Actions](https://neurosynq.github.io/parse-stack-next/Parse/Core/Actions.html).
|
|
2343
2381
|
|
|
@@ -4194,6 +4232,40 @@ You may change your local Parse ruby classes by adding new properties. To easily
|
|
|
4194
4232
|
|
|
4195
4233
|
```
|
|
4196
4234
|
|
|
4235
|
+
### Inspecting Schema Differences
|
|
4236
|
+
|
|
4237
|
+
`Parse::Schema.diff(Klass)` returns a `SchemaDiff` describing how your local
|
|
4238
|
+
model and the server schema differ:
|
|
4239
|
+
|
|
4240
|
+
- `#missing_on_server` — fields declared locally but absent on the server (what `auto_upgrade!` would add).
|
|
4241
|
+
- `#missing_locally` — columns present on the server but not declared in your model (e.g. dashboard-added fields). Informational only; never removed.
|
|
4242
|
+
- `#type_mismatches` — fields whose local type differs from the server's.
|
|
4243
|
+
- `#in_sync?` — `true` only when all three are empty (strict, **bidirectional** equality).
|
|
4244
|
+
- `#server_covers_local?` — `true` when every field your model declares is present on the server (`missing_on_server.empty? && type_mismatches.empty?`). One-way: server-only columns are ignored.
|
|
4245
|
+
- `#summary` — a human-readable report of the above.
|
|
4246
|
+
|
|
4247
|
+
```ruby
|
|
4248
|
+
diff = Parse::Schema.diff(Post)
|
|
4249
|
+
puts diff.summary
|
|
4250
|
+
diff.missing_on_server # => { published: :boolean }
|
|
4251
|
+
diff.missing_locally # => { "legacyFlag" => :boolean }
|
|
4252
|
+
```
|
|
4253
|
+
|
|
4254
|
+
**CI convergence check.** Do **not** gate CI on `in_sync?` — it is
|
|
4255
|
+
bidirectional and returns `false` whenever the server has extra columns (a
|
|
4256
|
+
dashboard-added field, or a column owned by another service), even right after
|
|
4257
|
+
a successful `auto_upgrade!`. Gate on the one-way check instead:
|
|
4258
|
+
|
|
4259
|
+
```ruby
|
|
4260
|
+
diff = Parse::Schema.diff(Post)
|
|
4261
|
+
unless diff.server_covers_local?
|
|
4262
|
+
abort "Post schema not converged:\n#{diff.summary}"
|
|
4263
|
+
end
|
|
4264
|
+
```
|
|
4265
|
+
|
|
4266
|
+
Server-only columns (`missing_locally`) are expected and safe — `auto_upgrade!`
|
|
4267
|
+
is purely additive and never drops them.
|
|
4268
|
+
|
|
4197
4269
|
## Push Notifications
|
|
4198
4270
|
Push notifications are implemented through the `Parse::Push` class. To send push notifications through the REST API, you must enable `REST push enabled?` option in the `Push Notification Settings` section of the `Settings` page in your Parse application. Push notifications targeting uses the Installation Parse class to determine which devices receive the notification. You can provide any query constraint, similar to using `Parse::Query`, in order to target the specific set of devices you want given the columns you have configured in your `Installation` class.
|
|
4199
4271
|
|
|
@@ -4500,7 +4572,16 @@ export PARSE_MCP_ENABLED=true
|
|
|
4500
4572
|
```
|
|
4501
4573
|
|
|
4502
4574
|
```ruby
|
|
4503
|
-
# Step 2:
|
|
4575
|
+
# Step 2: Connect to your Parse Server FIRST — the agent's tools query it,
|
|
4576
|
+
# so without an active client every tool call raises a connection error.
|
|
4577
|
+
Parse.setup(
|
|
4578
|
+
server_url: ENV["PARSE_SERVER_URL"], # e.g. "https://api.example.com/parse"
|
|
4579
|
+
application_id: ENV["PARSE_APP_ID"],
|
|
4580
|
+
api_key: ENV["PARSE_REST_API_KEY"],
|
|
4581
|
+
master_key: ENV["PARSE_MASTER_KEY"], # master-key agent (full read access)
|
|
4582
|
+
)
|
|
4583
|
+
|
|
4584
|
+
# Then enable and start the MCP server.
|
|
4504
4585
|
Parse.mcp_server_enabled = true
|
|
4505
4586
|
Parse::Agent.enable_mcp!(port: 3001)
|
|
4506
4587
|
Parse::Agent::MCPServer.run(api_key: ENV["MCP_API_KEY"])
|
|
@@ -4746,7 +4827,9 @@ end
|
|
|
4746
4827
|
```
|
|
4747
4828
|
|
|
4748
4829
|
### Cloud Code Triggers
|
|
4749
|
-
You can register webhooks to handle the different object triggers: `:before_save`, `:after_save`, `:before_delete` and `:after_delete`. The `payload` object, which is an instance of `Parse::Webhooks::Payload`, contains several properties that represent the payload. One of the most important ones is `parse_object`, which will provide you with the instance of your specific Parse object.
|
|
4830
|
+
You can register webhooks to handle the different object triggers: `:before_save`, `:after_save`, `:before_delete` and `:after_delete`. The `payload` object, which is an instance of `Parse::Webhooks::Payload`, contains several properties that represent the payload. One of the most important ones is `parse_object`, which will provide you with the instance of your specific Parse object.
|
|
4831
|
+
|
|
4832
|
+
The `parse_object` handed to your handler is the **full object as Parse Server sent it** — `createdAt`/`updatedAt`, `ACL`, and internal fields all survive (only live credentials — session tokens and password hashes — are stripped; `Parse::User` additionally protects `authData` on `payload.user`). Both `:before_save` and `:after_save` objects carry **dirty tracking** of what changed (`name_changed?`, `changes`), and `Parse::Object#existed?` / `#new?` are reliable inside `:after_save`. See [Trigger object state](#trigger-object-state) below.
|
|
4750
4833
|
|
|
4751
4834
|
```ruby
|
|
4752
4835
|
# recommended way
|
|
@@ -4772,6 +4855,81 @@ You can register webhooks to handle the different object triggers: `:before_save
|
|
|
4772
4855
|
|
|
4773
4856
|
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.
|
|
4774
4857
|
|
|
4858
|
+
> **Your model's `after_save` callbacks run here too.** When an `after_save` /
|
|
4859
|
+
> `after_create` trigger fires, the webhook rebuilds the `Parse::Object` from the
|
|
4860
|
+
> payload and runs that model's ActiveModel `after_save` / `after_create`
|
|
4861
|
+
> callbacks — so a `webhook :after_save` block and a model `after_save :method`
|
|
4862
|
+
> callback are part of the same flow. They fire **exactly once** per save: for
|
|
4863
|
+
> saves initiated by this Ruby SDK (recognized by the `_RB_` request-id prefix
|
|
4864
|
+
> together with the master key), Parse Stack already ran them locally after the
|
|
4865
|
+
> REST response, so the webhook skips them to avoid double-firing side effects;
|
|
4866
|
+
> for saves from other clients (JS / iOS / REST), the webhook runs them, since
|
|
4867
|
+
> the SDK never had the chance.
|
|
4868
|
+
|
|
4869
|
+
#### Trigger object state
|
|
4870
|
+
|
|
4871
|
+
Because the trigger payload is server-authoritative, the `parse_object` your
|
|
4872
|
+
handler receives is the complete object, and the usual `Parse::Object`
|
|
4873
|
+
introspection works inside the trigger:
|
|
4874
|
+
|
|
4875
|
+
| What you want to know | In `:before_save` | In `:after_save` |
|
|
4876
|
+
|---|---|---|
|
|
4877
|
+
| Is this a create or an update? | `parse_object.new?` (`true` = create) | `parse_object.existed?` (`false` = create) or `payload.original.nil?` |
|
|
4878
|
+
| What changed? | `name_changed?`, `changes`, `changed` | `name_changed?`, `changes`, `changed` (relative to the prior state) |
|
|
4879
|
+
| Server timestamps | not yet assigned (`new?` create) | `created_at` / `updated_at` populated |
|
|
4880
|
+
| The prior stored values | `payload.original_parse_object` | `payload.original_parse_object` |
|
|
4881
|
+
|
|
4882
|
+
Use `new?` in `:before_save` and `existed?` in `:after_save`. In `:after_save`
|
|
4883
|
+
the object is already persisted, so `new?` is `false` for both creates and
|
|
4884
|
+
updates — `existed?` (`created_at != updated_at`) is the create/update signal,
|
|
4885
|
+
equivalently `payload.original.nil?`.
|
|
4886
|
+
|
|
4887
|
+
```ruby
|
|
4888
|
+
Parse::Webhooks.route :after_save, :Post do
|
|
4889
|
+
post = parse_object
|
|
4890
|
+
if post.existed?
|
|
4891
|
+
Search.reindex(post) if post.title_changed? # update
|
|
4892
|
+
else
|
|
4893
|
+
post.create_default_associations! # first save
|
|
4894
|
+
end
|
|
4895
|
+
true
|
|
4896
|
+
end
|
|
4897
|
+
```
|
|
4898
|
+
|
|
4899
|
+
**Lifecycle callback order.** Parse Server has no separate `beforeCreate` /
|
|
4900
|
+
`afterCreate` triggers — only `beforeSave` and `afterSave`. The SDK runs your
|
|
4901
|
+
model's ActiveModel callbacks in canonical order across the two webhooks:
|
|
4902
|
+
|
|
4903
|
+
```
|
|
4904
|
+
beforeSave webhook : before_save → before_create (before_create only for new objects)
|
|
4905
|
+
[Parse Server persists]
|
|
4906
|
+
afterSave webhook : after_create → after_save (after_create only for new objects)
|
|
4907
|
+
```
|
|
4908
|
+
|
|
4909
|
+
So a model `before_create` / `after_create` callback runs for objects created by
|
|
4910
|
+
**any** client (REST / JS cloud code / Auth0 / iOS), not just Ruby-model saves —
|
|
4911
|
+
provided the corresponding trigger is registered with Parse Server (see
|
|
4912
|
+
[Register Webhooks](#register-webhooks)). These callbacks fire **once** per save;
|
|
4913
|
+
Ruby-SDK-initiated saves run them locally and the webhook skips them to avoid
|
|
4914
|
+
double-firing. `:if`/`:unless` conditions on these callbacks are honored.
|
|
4915
|
+
|
|
4916
|
+
> **`before_update` / `after_update` do not run from webhooks.** The webhook
|
|
4917
|
+
> layer runs `before_save` / `before_create` / `after_create` / `after_save`
|
|
4918
|
+
> only. The `:update`-specific callbacks fire on Ruby-model saves but **not**
|
|
4919
|
+
> for client-initiated (REST / JS / Auth0) saves, because Parse Server has no
|
|
4920
|
+
> `beforeUpdate` / `afterUpdate` trigger. For update-time logic that must run
|
|
4921
|
+
> for all clients, use `before_save` / `after_save` and branch on `existed?`.
|
|
4922
|
+
|
|
4923
|
+
> **Keep `after_save` handlers fast.** Parse Server **waits** for the `after_save`
|
|
4924
|
+
> webhook response before returning to the saving client (only LiveQuery events
|
|
4925
|
+
> are truly fire-and-forget), so a slow handler adds latency to that client's
|
|
4926
|
+
> save. And because Parse Server swallows afterSave errors and never retries the
|
|
4927
|
+
> trigger, blocking on slow work buys you no durability. Do trivial work inline
|
|
4928
|
+
> and hand anything slow, external, or must-not-be-lost (notifications,
|
|
4929
|
+
> downstream writes) to a background job/worker, returning quickly. This matters
|
|
4930
|
+
> most for client-initiated saves, where the callback runs inside the webhook —
|
|
4931
|
+
> Ruby-SDK saves run it in-process after their own REST response instead.
|
|
4932
|
+
|
|
4775
4933
|
`before_save` and `before_delete` hooks have special functionality and multiple ways to halt operations:
|
|
4776
4934
|
|
|
4777
4935
|
1. **Using `error!` method**: Calling `error!` will return an error response to Parse Server
|
|
@@ -5520,46 +5678,71 @@ The integration tests use Docker Compose to spin up a Parse Server instance with
|
|
|
5520
5678
|
|
|
5521
5679
|
#### Docker Configuration
|
|
5522
5680
|
|
|
5523
|
-
The
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5681
|
+
The integration stack is defined in `scripts/docker/docker-compose.test.yml`
|
|
5682
|
+
(Parse Server, MongoDB, Redis, and the Parse Dashboard); the Atlas Search stack
|
|
5683
|
+
is in `scripts/docker/docker-compose.atlas.yml`. It is deliberately isolated
|
|
5684
|
+
from any other Parse test system on the same host — a dedicated Compose project,
|
|
5685
|
+
a private port block, and a dedicated database name — so two Parse stacks can
|
|
5686
|
+
run side by side without colliding.
|
|
5687
|
+
|
|
5688
|
+
Default host ports (each overridable via the env var shown):
|
|
5689
|
+
|
|
5690
|
+
| Service | Host port | Override env var |
|
|
5691
|
+
|----------------------|-----------|-----------------------|
|
|
5692
|
+
| Parse Server | 29337 | `PARSE_HOST_PORT` |
|
|
5693
|
+
| MongoDB (test) | 29017 | `MONGO_HOST_PORT` |
|
|
5694
|
+
| Redis | 29379 | `REDIS_HOST_PORT` |
|
|
5695
|
+
| Parse Dashboard | 29040 | `DASHBOARD_HOST_PORT` |
|
|
5696
|
+
| MongoDB Atlas Local | 29020 | `ATLAS_HOST_PORT` |
|
|
5697
|
+
|
|
5698
|
+
Identity and naming:
|
|
5699
|
+
|
|
5700
|
+
- Containers, network, and volumes are namespaced by the Compose project
|
|
5701
|
+
`psnext-it`. Override the prefix with `PSNEXT_PREFIX` (e.g.
|
|
5702
|
+
`PSNEXT_PREFIX=psnext-ci`) to run a second, fully separate copy of the stack.
|
|
5703
|
+
- Parse database name: `parse_stack_next_it`. Atlas database: `parse_atlas_test`.
|
|
5704
|
+
- Default credentials: app id `psnextItAppId`, master key `psnextItMasterKey`,
|
|
5705
|
+
REST key `psnext-it-rest-key` (override with `PARSE_APP_ID`,
|
|
5706
|
+
`PARSE_MASTER_KEY`, `PARSE_API_KEY`).
|
|
5707
|
+
|
|
5708
|
+
Bring the stack up and verify:
|
|
5709
|
+
|
|
5710
|
+
```bash
|
|
5711
|
+
docker compose -f scripts/docker/docker-compose.test.yml up -d
|
|
5712
|
+
curl -s http://localhost:29337/parse/health # -> {"status":"ok"}
|
|
5545
5713
|
```
|
|
5546
5714
|
|
|
5547
5715
|
#### Environment Variables
|
|
5548
5716
|
|
|
5549
|
-
|
|
5717
|
+
The defaults above are baked into the Compose file and the test helpers, so the
|
|
5718
|
+
suite is isolated out of the box. To re-point anything, export the variables in
|
|
5719
|
+
your shell before running (nothing auto-loads `.env.test` — it is a committed
|
|
5720
|
+
reference of the full set; `set -a; source .env.test; set +a` loads them all at
|
|
5721
|
+
once). There are two sides — the containers and the Ruby client — and when you
|
|
5722
|
+
move a port you set both so they agree:
|
|
5550
5723
|
|
|
5551
5724
|
```bash
|
|
5552
|
-
# Required
|
|
5725
|
+
# Required to route the suite at the Docker stack
|
|
5553
5726
|
export PARSE_TEST_USE_DOCKER=true
|
|
5554
5727
|
|
|
5555
|
-
#
|
|
5556
|
-
export
|
|
5557
|
-
export
|
|
5558
|
-
export
|
|
5559
|
-
export
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
export
|
|
5728
|
+
# Compose side — what the containers publish / use
|
|
5729
|
+
export PSNEXT_PREFIX=psnext-it
|
|
5730
|
+
export PARSE_HOST_PORT=29337
|
|
5731
|
+
export MONGO_HOST_PORT=29017
|
|
5732
|
+
export REDIS_HOST_PORT=29379
|
|
5733
|
+
export PARSE_APP_ID=psnextItAppId
|
|
5734
|
+
export PARSE_MASTER_KEY=psnextItMasterKey
|
|
5735
|
+
export PARSE_API_KEY=psnext-it-rest-key
|
|
5736
|
+
|
|
5737
|
+
# Client side — what the Ruby test suite connects to
|
|
5738
|
+
export PARSE_TEST_SERVER_URL=http://localhost:29337/parse
|
|
5739
|
+
export PARSE_TEST_APP_ID=psnextItAppId
|
|
5740
|
+
export PARSE_TEST_API_KEY=psnext-it-rest-key
|
|
5741
|
+
export PARSE_TEST_MASTER_KEY=psnextItMasterKey
|
|
5742
|
+
export PARSE_TEST_MONGO_URI="mongodb://admin:password@localhost:29017/parse_stack_next_it?authSource=admin"
|
|
5743
|
+
export PARSE_TEST_REDIS_URL=redis://localhost:29379/0
|
|
5744
|
+
export PARSE_TEST_LIVE_QUERY_URL=ws://localhost:29337
|
|
5745
|
+
export ATLAS_URI="mongodb://localhost:29020/parse_atlas_test?directConnection=true"
|
|
5563
5746
|
```
|
|
5564
5747
|
|
|
5565
5748
|
#### Troubleshooting
|
|
@@ -5572,9 +5755,13 @@ export REDIS_URL=redis://localhost:6379
|
|
|
5572
5755
|
docker-compose --version
|
|
5573
5756
|
```
|
|
5574
5757
|
|
|
5575
|
-
2. **Port conflicts**:
|
|
5758
|
+
2. **Port conflicts**: The stack uses a dedicated `29xxx` block (29337 / 29017 /
|
|
5759
|
+
29379 / 29040 / 29020) specifically to avoid colliding with a default Parse
|
|
5760
|
+
setup (1337 / 27017 / 6379 / 4040). If something still holds one of those
|
|
5761
|
+
ports, override it (for example `PARSE_HOST_PORT=29338`) or stop the
|
|
5762
|
+
conflicting stack:
|
|
5576
5763
|
```bash
|
|
5577
|
-
docker
|
|
5764
|
+
docker compose -f scripts/docker/docker-compose.test.yml down
|
|
5578
5765
|
```
|
|
5579
5766
|
|
|
5580
5767
|
3. **Permission errors**: Ensure Docker has proper permissions
|
data/Rakefile
CHANGED
|
@@ -14,7 +14,7 @@ require "rake/testtask"
|
|
|
14
14
|
# @return [Array(String, String, String, String)]
|
|
15
15
|
# server_url, application_id, api_key, master_key
|
|
16
16
|
def mcp_credentials_or_abort!
|
|
17
|
-
server_url = ENV["PARSE_SERVER_URL"] || "http://localhost:
|
|
17
|
+
server_url = ENV["PARSE_SERVER_URL"] || "http://localhost:29337/parse"
|
|
18
18
|
app_id = ENV["PARSE_APP_ID"]
|
|
19
19
|
rest_api_key = ENV["PARSE_API_KEY"]
|
|
20
20
|
master_key = ENV["PARSE_MASTER_KEY"]
|
|
@@ -23,9 +23,9 @@ def mcp_credentials_or_abort!
|
|
|
23
23
|
|
|
24
24
|
if app_id.to_s.empty? || master_key.to_s.empty?
|
|
25
25
|
if is_local
|
|
26
|
-
app_id = (app_id.to_s.empty? ? "
|
|
26
|
+
app_id = (app_id.to_s.empty? ? "psnextItAppId" : app_id)
|
|
27
27
|
rest_api_key = (rest_api_key.to_s.empty? ? "myApiKey" : rest_api_key)
|
|
28
|
-
master_key = (master_key.to_s.empty? ? "
|
|
28
|
+
master_key = (master_key.to_s.empty? ? "psnextItMasterKey" : master_key)
|
|
29
29
|
else
|
|
30
30
|
abort "[Rakefile] PARSE_SERVER_URL=#{server_url} is not local; refusing to fall back to " \
|
|
31
31
|
"placeholder credentials. Set PARSE_APP_ID and PARSE_MASTER_KEY explicitly."
|
|
@@ -35,11 +35,15 @@ def mcp_credentials_or_abort!
|
|
|
35
35
|
[server_url, app_id, rest_api_key, master_key]
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Default test task runs all tests with Docker enabled
|
|
38
|
+
# Default test task runs all tests with Docker enabled.
|
|
39
|
+
#
|
|
40
|
+
# `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
|
|
41
|
+
# Parse Server container, which would flake any other test loaded into the
|
|
42
|
+
# same process. Run them on their own via `rake test:integration:disruptive`.
|
|
39
43
|
Rake::TestTask.new do |t|
|
|
40
44
|
ENV['PARSE_TEST_USE_DOCKER'] = 'true'
|
|
41
45
|
t.libs << "lib/parse/stack"
|
|
42
|
-
t.test_files = FileList["test/lib/**/*_test.rb"]
|
|
46
|
+
t.test_files = FileList["test/lib/**/*_test.rb"].exclude("test/lib/**/*disruptive*")
|
|
43
47
|
t.warning = false
|
|
44
48
|
t.verbose = true
|
|
45
49
|
end
|
|
@@ -48,8 +52,12 @@ end
|
|
|
48
52
|
namespace :test do
|
|
49
53
|
desc "Run all integration tests (requires Docker)"
|
|
50
54
|
task :integration do
|
|
55
|
+
# Disruptive tests (server stop/restart) are run separately via
|
|
56
|
+
# `test:integration:disruptive` so they never interleave with — and
|
|
57
|
+
# flake — the rest of the integration suite against the shared server.
|
|
51
58
|
integration_files = FileList["test/lib/**/*integration_test.rb"]
|
|
52
|
-
|
|
59
|
+
.exclude("test/lib/**/*disruptive*")
|
|
60
|
+
|
|
53
61
|
puts "Running #{integration_files.length} integration test files..."
|
|
54
62
|
integration_files.each_with_index do |file, index|
|
|
55
63
|
puts "Running integration test #{index + 1}/#{integration_files.length}: #{file}"
|
|
@@ -71,8 +79,10 @@ namespace :test do
|
|
|
71
79
|
|
|
72
80
|
desc "Run unit tests only (no Docker required)"
|
|
73
81
|
task :unit do
|
|
74
|
-
unit_files = FileList["test/lib/**/*_test.rb"]
|
|
75
|
-
|
|
82
|
+
unit_files = FileList["test/lib/**/*_test.rb"]
|
|
83
|
+
.exclude("test/lib/**/*integration_test.rb")
|
|
84
|
+
.exclude("test/lib/**/*disruptive*")
|
|
85
|
+
|
|
76
86
|
puts "Running #{unit_files.length} unit test files (no Docker)..."
|
|
77
87
|
unit_files.each_with_index do |file, index|
|
|
78
88
|
puts "Running unit test #{index + 1}/#{unit_files.length}: #{file}"
|
|
@@ -89,13 +99,49 @@ namespace :test do
|
|
|
89
99
|
puts "\n✅ All unit tests completed successfully!"
|
|
90
100
|
end
|
|
91
101
|
|
|
102
|
+
namespace :integration do
|
|
103
|
+
desc "Run DISRUPTIVE integration tests (stop/restart the Parse Server " \
|
|
104
|
+
"container). Run in isolation — these are excluded from the normal " \
|
|
105
|
+
"test / test:integration / test:unit runs."
|
|
106
|
+
task :disruptive do
|
|
107
|
+
disruptive_files = FileList["test/lib/**/*disruptive*_test.rb"]
|
|
108
|
+
|
|
109
|
+
if disruptive_files.empty?
|
|
110
|
+
puts "No disruptive test files found."
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
puts "Running #{disruptive_files.length} disruptive test file(s)..."
|
|
115
|
+
disruptive_files.each_with_index do |file, index|
|
|
116
|
+
puts "\n" + "=" * 80
|
|
117
|
+
puts "Running disruptive test #{index + 1}/#{disruptive_files.length}: #{file}"
|
|
118
|
+
puts "=" * 80
|
|
119
|
+
# Each file runs in its own process so a server outage in one cannot
|
|
120
|
+
# bleed into the next.
|
|
121
|
+
system("PARSE_TEST_USE_DOCKER=true ruby -Ilib:test #{file}") || begin
|
|
122
|
+
# A disruptive test may have left the server down on failure; bring
|
|
123
|
+
# it back so a follow-up run / other tasks start from a clean state.
|
|
124
|
+
system("docker start #{ENV["PSNEXT_PREFIX"] || "psnext-it"}-server", out: IO::NULL, err: IO::NULL)
|
|
125
|
+
exit(1)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
puts "\n✅ All disruptive tests completed successfully!"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
92
132
|
desc "List all available test files"
|
|
93
133
|
task :list do
|
|
94
134
|
puts "\nIntegration Tests:"
|
|
95
|
-
FileList["test/lib/**/*integration_test.rb"].each { |f| puts " #{f}" }
|
|
135
|
+
FileList["test/lib/**/*integration_test.rb"].exclude("test/lib/**/*disruptive*").each { |f| puts " #{f}" }
|
|
136
|
+
|
|
137
|
+
puts "\nDisruptive Integration Tests (run via test:integration:disruptive):"
|
|
138
|
+
FileList["test/lib/**/*disruptive*_test.rb"].each { |f| puts " #{f}" }
|
|
96
139
|
|
|
97
140
|
puts "\nUnit Tests:"
|
|
98
|
-
FileList["test/lib/**/*_test.rb"]
|
|
141
|
+
FileList["test/lib/**/*_test.rb"]
|
|
142
|
+
.exclude("test/lib/**/*integration_test.rb")
|
|
143
|
+
.exclude("test/lib/**/*disruptive*")
|
|
144
|
+
.each { |f| puts " #{f}" }
|
|
99
145
|
end
|
|
100
146
|
|
|
101
147
|
# ---------------------------------------------------------------------------
|