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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.env.sample +12 -0
  3. data/.env.test +4 -4
  4. data/CHANGELOG.md +630 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +6 -1
  7. data/README.md +226 -39
  8. data/Rakefile +56 -10
  9. data/docs/atlas_vector_search_guide.md +110 -9
  10. data/docs/mcp_guide.md +504 -0
  11. data/docs/mongodb_direct_guide.md +66 -1
  12. data/docs/mongodb_index_optimization_guide.md +22 -1
  13. data/docs/usage_guide.md +15 -0
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +90 -19
  16. data/lib/parse/agent/describe.rb +1 -0
  17. data/lib/parse/agent/errors.rb +16 -0
  18. data/lib/parse/agent/mcp_client.rb +9 -0
  19. data/lib/parse/agent/mcp_dispatcher.rb +139 -7
  20. data/lib/parse/agent/mcp_rack_app.rb +621 -17
  21. data/lib/parse/agent/mcp_subscriptions.rb +607 -0
  22. data/lib/parse/agent/metadata_dsl.rb +58 -0
  23. data/lib/parse/agent/metadata_registry.rb +141 -1
  24. data/lib/parse/agent/prompt_hardening.rb +213 -0
  25. data/lib/parse/agent/result_formatter.rb +18 -3
  26. data/lib/parse/agent/tools.rb +167 -24
  27. data/lib/parse/agent.rb +692 -21
  28. data/lib/parse/client/request.rb +55 -4
  29. data/lib/parse/client/response.rb +4 -0
  30. data/lib/parse/client.rb +205 -7
  31. data/lib/parse/model/classes/installation.rb +27 -10
  32. data/lib/parse/model/classes/user.rb +8 -0
  33. data/lib/parse/model/core/actions.rb +65 -13
  34. data/lib/parse/model/core/embed_managed.rb +19 -14
  35. data/lib/parse/model/core/indexing.rb +108 -16
  36. data/lib/parse/model/core/querying.rb +29 -0
  37. data/lib/parse/model/model.rb +34 -3
  38. data/lib/parse/model/object.rb +42 -0
  39. data/lib/parse/query.rb +90 -24
  40. data/lib/parse/retrieval/agent_tool.rb +369 -0
  41. data/lib/parse/retrieval/chunk.rb +74 -0
  42. data/lib/parse/retrieval/chunker.rb +208 -0
  43. data/lib/parse/retrieval/retriever.rb +274 -0
  44. data/lib/parse/retrieval.rb +10 -0
  45. data/lib/parse/schema.rb +69 -20
  46. data/lib/parse/stack/version.rb +2 -2
  47. data/lib/parse/webhooks/payload.rb +62 -34
  48. data/lib/parse/webhooks.rb +15 -3
  49. data/parse-stack-next.gemspec +1 -1
  50. data/scripts/docker/docker-compose.atlas.yml +14 -10
  51. data/scripts/docker/docker-compose.test.yml +24 -20
  52. data/scripts/docker/mongo-init.js +3 -3
  53. data/scripts/start-parse.sh +10 -0
  54. data/scripts/start_mcp_server.rb +1 -1
  55. data/scripts/test_server_connection.rb +1 -1
  56. data/scripts/vector_prototype/create_vector_index.js +1 -1
  57. data/scripts/vector_prototype/fetch_embeddings.py +2 -2
  58. data/scripts/vector_prototype/query_prototype.rb +1 -1
  59. data/scripts/vector_prototype/run.sh +4 -4
  60. 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.1.1)
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 (requires master key)
1567
- user.disable_mfa_admin!
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. 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.
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: Enable in code and start server
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. In `:before_save` triggers, this object already contains dirty tracking information of what has been changed.
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 tests use the following Docker setup:
5524
-
5525
- ```yaml
5526
- # docker-compose.test.yml
5527
- version: '3.8'
5528
- services:
5529
- mongo:
5530
- image: mongo:4.4
5531
- environment:
5532
- MONGO_INITDB_ROOT_USERNAME: root
5533
- MONGO_INITDB_ROOT_PASSWORD: password
5534
-
5535
- redis:
5536
- image: redis:6-alpine
5537
-
5538
- parse-server:
5539
- image: parseplatform/parse-server:latest
5540
- environment:
5541
- PARSE_SERVER_APPLICATION_ID: testAppId
5542
- PARSE_SERVER_MASTER_KEY: testMasterKey
5543
- PARSE_SERVER_DATABASE_URI: mongodb://root:password@mongo:27017/parse?authSource=admin
5544
- PARSE_SERVER_REDIS_URL: redis://redis:6379
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
- Configure the following environment variables for testing:
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 for Docker tests
5725
+ # Required to route the suite at the Docker stack
5553
5726
  export PARSE_TEST_USE_DOCKER=true
5554
5727
 
5555
- # Optional: Custom Parse Server configuration
5556
- export PARSE_SERVER_URL=http://localhost:2337/parse
5557
- export PARSE_APP_ID=testAppId
5558
- export PARSE_MASTER_KEY=testMasterKey
5559
- export PARSE_API_KEY=testRestKey
5560
-
5561
- # Optional: Redis configuration for cache tests
5562
- export REDIS_URL=redis://localhost:6379
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**: Stop other services using ports 1337, 27017, or 6379
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-compose -f docker-compose.test.yml down
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:2337/parse"
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? ? "myAppId" : app_id)
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? ? "myMasterKey" : master_key)
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"].exclude("test/lib/**/*integration_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"].exclude("test/lib/**/*integration_test.rb").each { |f| puts " #{f}" }
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
  # ---------------------------------------------------------------------------