parse-stack-next 4.5.0 → 5.0.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
@@ -211,7 +211,7 @@ convert into the Parse JSON shape, then `Song.new(doc)` or
211
211
  Authorization kwargs (v4.4.0) — pass at most ONE:
212
212
 
213
213
  - `session_token: <bearer>` — round-trips Parse Server's `/users/me`
214
- to resolve the user and expand role membership.
214
+ to resolve the user and expand role subscription.
215
215
  - `acl_user: <Parse::User or Pointer>` — pre-resolved identity, skips
216
216
  the token round-trip. Role expansion runs via `Parse::Role.all_for_user`.
217
217
  - `acl_role: <Parse::Role or name>` — service-account scope; no user_id,
@@ -352,7 +352,7 @@ pipeline = [
352
352
  { "$project" => { "contributor_count" => { "$size" => "$contributor_set" } } },
353
353
  ]
354
354
 
355
- Parse::MongoDB.aggregate("Capture", pipeline)
355
+ Parse::MongoDB.aggregate("Post", pipeline)
356
356
  # => [{ "contributor_count" => 27 }]
357
357
  # row keyed by the literal alias, no read-side translation needed
358
358
  ```
@@ -740,6 +740,23 @@ authorization model for scoped callers and is the SAFER aggregation
740
740
  path for any user-context workload — REST aggregate has no ACL/CLP
741
741
  enforcement at all, while mongo-direct with a scope gets all of it.
742
742
 
743
+ ### Vector search inherits the 5-layer enforcement
744
+
745
+ `Klass.find_similar(vector:, k:)` (and the `text:` variant that
746
+ auto-embeds) is built on top of `Parse::MongoDB.aggregate` — the
747
+ $vectorSearch stage is prepended, then the same Layer 1-5 chain runs
748
+ against the result rows. Vector search inherits the pipeline-security
749
+ denylist, `_rperm` ACL match, CLP read enforcement, `protectedFields`
750
+ stripping, and master-key escape hatch automatically. There is no
751
+ REST-aggregate path for vector search: scoped callers MUST use the
752
+ mongo-direct path because Parse Server's REST `/aggregate` endpoint is
753
+ master-key-only and would bypass every per-row ACL and CLP check.
754
+ Built-in vector tools auto-promote `mongo_direct: false` to `true` for
755
+ any agent that carries a `session_token`, `acl_user`, `acl_role`, or
756
+ non-master scope so this enforcement always runs. See the
757
+ [Atlas Vector Search Guide](./atlas_vector_search_guide.md) for the
758
+ full API surface.
759
+
743
760
  ### Timeouts
744
761
 
745
762
  ```ruby
@@ -884,7 +901,7 @@ the blast radius; the URI controls routing.
884
901
 
885
902
  If you must hard-guarantee that direct queries cannot touch primary or
886
903
  electable secondaries — for example, because you want to give the
887
- endpoint to an external team or an autonomous agent — the connection
904
+ endpoint to an external workspace or an autonomous agent — the connection
888
905
  string alone is insufficient. The options:
889
906
 
890
907
  - **Atlas SQL / BI Connector.** Issue the user a JDBC/SQL endpoint that
@@ -973,7 +990,7 @@ Validation rules enforced at declaration time:
973
990
  single-field declarations per MongoDB's rules.
974
991
  - `unique:` on `mongo_relation_index` → `ArgumentError`. A
975
992
  single-direction unique on a `has_many :through: :relation` would
976
- contradict `has_many` semantics. For no-duplicate-pair membership,
993
+ contradict `has_many` semantics. For no-duplicate-pair subscription,
977
994
  declare a compound unique index directly via `Parse::MongoDB.create_index`.
978
995
 
979
996
  `parse_reference` auto-registers a unique-sparse index declaration on
@@ -0,0 +1,585 @@
1
+ # Parse Stack Usage Guide
2
+
3
+ A practical guide to using Parse Stack for Ruby applications.
4
+
5
+ ## Setup
6
+
7
+ ```ruby
8
+ require 'parse/stack'
9
+
10
+ Parse.setup(
11
+ server_url: 'https://your-server.com/parse',
12
+ app_id: 'your_app_id',
13
+ api_key: 'your_rest_api_key',
14
+ master_key: 'your_master_key' # optional
15
+ )
16
+ ```
17
+
18
+ ## Defining Models
19
+
20
+ ```ruby
21
+ class Song < Parse::Object
22
+ property :title, :string, required: true
23
+ property :artist, :string
24
+ property :plays, :integer, default: 0
25
+ property :duration, :float
26
+ property :released, :date
27
+ property :tags, :array
28
+ property :metadata, :object
29
+
30
+ belongs_to :album
31
+ has_many :comments
32
+ end
33
+ ```
34
+
35
+ ## CRUD Operations
36
+
37
+ ```ruby
38
+ # Create
39
+ song = Song.new(title: "My Song", artist: "Artist")
40
+ song.save
41
+
42
+ # or
43
+ song = Song.create!(title: "My Song", artist: "Artist")
44
+
45
+ # Read
46
+ song = Song.find("objectId")
47
+ song = Song.first(title: "My Song")
48
+ songs = Song.all(limit: 100)
49
+
50
+ # Update
51
+ song.title = "New Title"
52
+ song.save
53
+
54
+ # Delete
55
+ song.destroy
56
+ ```
57
+
58
+ ## Queries
59
+
60
+ ```ruby
61
+ # Basic queries
62
+ Song.where(artist: "Artist Name").results
63
+ Song.query(genre: "rock").limit(10).results
64
+
65
+ # Comparison operators
66
+ Song.where(:plays.gt => 1000).results # greater than
67
+ Song.where(:plays.gte => 1000).results # greater than or equal
68
+ Song.where(:plays.lt => 100).results # less than
69
+ Song.where(:plays.between => [100, 1000]).results
70
+
71
+ # String matching
72
+ Song.where(:title.like => /rock/i).results # regex
73
+ Song.where(:title.starts_with => "The").results
74
+ Song.where(:title.ends_with => ".mp3").results
75
+
76
+ # Array operations
77
+ Song.where(:tags.in => ["rock", "pop"]).results
78
+ Song.where(:tags.all => ["rock", "guitar"]).results
79
+
80
+ # Sorting and pagination
81
+ Song.query.order(:plays.desc).skip(10).limit(20).results
82
+
83
+ # Include related objects
84
+ Song.all(includes: [:album, :comments])
85
+
86
+ # Select specific fields
87
+ Song.all(keys: [:title, :artist])
88
+ ```
89
+
90
+ ## Aggregation
91
+
92
+ ```ruby
93
+ # Group by with aggregation — chain the aggregator you want
94
+ Song.group_by(:artist).count
95
+ Song.group_by(:artist).sum(:plays)
96
+ Song.group_by(:artist).average(:duration)
97
+
98
+ # Group by date
99
+ Song.group_by_date(:released, :month, timezone: "America/New_York").count
100
+
101
+ # Count distinct values
102
+ Song.query.count_distinct(:artist)
103
+
104
+ # Custom pipeline
105
+ Song.query.aggregate([
106
+ { "$match" => { "plays" => { "$gt" => 1000 } } },
107
+ { "$group" => { "_id" => "$artist", "total" => { "$sum" => "$plays" } } }
108
+ ])
109
+ ```
110
+
111
+ ## Transactions
112
+
113
+ ```ruby
114
+ Parse::Object.transaction do |batch|
115
+ song.plays += 1
116
+ batch.add(song)
117
+
118
+ artist.total_plays += 1
119
+ batch.add(artist)
120
+ end
121
+ ```
122
+
123
+ ## Upsert Operations
124
+
125
+ ```ruby
126
+ # Find or create (returns unsaved if new)
127
+ song = Song.first_or_create({ title: "My Song" }, { artist: "Unknown" })
128
+
129
+ # Find or create and save
130
+ song = Song.first_or_create!({ title: "My Song" }, { artist: "Unknown" })
131
+
132
+ # Create or update existing
133
+ song = Song.create_or_update!({ title: "My Song" }, { plays: 100 })
134
+ ```
135
+
136
+ ## ACLs (Access Control)
137
+
138
+ ```ruby
139
+ # Per-instance permissions
140
+ song.acl.apply(:public, read: true, write: false)
141
+ song.acl.apply(user, read: true, write: true)
142
+ song.acl.apply_role("Admin", read: true, write: true)
143
+
144
+ # Query by ACL
145
+ Song.query.publicly_readable.results
146
+ Song.query.readable_by(current_user).results
147
+ Song.query.readable_by_role("Admin").results
148
+
149
+ # Class-level default ACL policy (v4.1+)
150
+ class Post < Parse::Object
151
+ belongs_to :author, as: :user
152
+ # Grant R/W to the author at save; fall back to master-key-only.
153
+ acl_policy :owner_else_private, owner: :author
154
+ end
155
+
156
+ Post.create!(title: "draft", author: current_user)
157
+ # → ACL: { "<current_user.id>": { read: true, write: true } }
158
+
159
+ # `as:` overrides any owner field for one-off ownership
160
+ Post.create!({ title: "x" }, as: current_user)
161
+
162
+ # For self-owned Parse::User records (one-roundtrip self-only ACL on signup)
163
+ class Parse::User
164
+ acl_policy :owner_else_private, owner: :self
165
+ end
166
+ ```
167
+
168
+ The gem-wide default is `:owner_else_private`. Records with no
169
+ resolvable owner are saved master-key-only. Declare
170
+ `acl_policy :public` or `:owner_else_public` on classes that need
171
+ public access.
172
+
173
+ Read-only and "publish-by-one-author" variants (v5.0+):
174
+
175
+ ```ruby
176
+ # Read-anywhere, master-key-only write (no client can mutate)
177
+ class Country < Parse::Object
178
+ property :name
179
+ acl_policy :public_read
180
+ end
181
+
182
+ # Owner R/W + public read in the same ACL.
183
+ # Falls back to public-read-only when no owner resolves.
184
+ class PublishedPost < Parse::Object
185
+ property :body
186
+ belongs_to :author, as: :user
187
+ acl_policy :owner_but_public_read, owner: :author
188
+ end
189
+ ```
190
+
191
+ Valid policies: `:public`, `:public_read`, `:private`,
192
+ `:owner_else_public`, `:owner_else_private`, `:owner_but_public_read`.
193
+
194
+ ## Roles
195
+
196
+ ```ruby
197
+ # Find or create a role
198
+ admin = Parse::Role.find_or_create("Admin")
199
+
200
+ # Add users
201
+ admin.add_users(user1, user2).save
202
+
203
+ # Role hierarchy — Admins inherit Moderator capabilities.
204
+ # Parse Server semantics: when role X holds role Y in its `roles`
205
+ # relation, users-of-Y inherit X's permissions. The direction-explicit
206
+ # helpers below make intent obvious.
207
+ moderator = Parse::Role.find_or_create("Moderator")
208
+ admin.inherits_capabilities_from!(moderator)
209
+ # equivalent: moderator.grant_capabilities_to!(admin)
210
+
211
+ # Get all users (including inherited roles)
212
+ moderator.all_users
213
+ ```
214
+
215
+ ## Class-Level Permissions (CLP)
216
+
217
+ ```ruby
218
+ class Document < Parse::Object
219
+ # Operation permissions
220
+ set_clp :find, public: true
221
+ set_clp :delete, public: false, roles: ["Admin"]
222
+
223
+ # Protect sensitive fields
224
+ protect_fields "*", [:internal_notes, :secret_data]
225
+ protect_fields "role:Admin", [] # Admins see everything
226
+ end
227
+
228
+ # Push to server
229
+ Document.auto_upgrade!
230
+ ```
231
+
232
+ ## Push Notifications
233
+
234
+ ```ruby
235
+ # Simple push
236
+ Parse::Push.new
237
+ .to_channel("news")
238
+ .with_alert("Breaking news!")
239
+ .send!
240
+
241
+ # Rich push
242
+ Parse::Push.new
243
+ .to_channels(["sports", "news"])
244
+ .with_title("Game Update")
245
+ .with_body("Score: 3-2")
246
+ .with_badge(1)
247
+ .schedule(Time.now + 3600)
248
+ .send!
249
+ ```
250
+
251
+ ## Caching
252
+
253
+ ```ruby
254
+ # Enable caching in setup
255
+ Parse.setup(
256
+ # ... other options
257
+ cache: Moneta.new(:Memory),
258
+ expires: 300 # 5 minutes
259
+ )
260
+
261
+ # Fetch with cache
262
+ song = Song.find_cached("objectId")
263
+ song.fetch_cache!
264
+
265
+ # Bypass cache
266
+ song = Song.find("objectId", cache: false)
267
+ ```
268
+
269
+ ## Direct MongoDB Access
270
+
271
+ For high-performance reads, bypass Parse Server:
272
+
273
+ ```ruby
274
+ # Configure MongoDB
275
+ Parse::MongoDB.configure(
276
+ uri: "mongodb://localhost:27017/parse",
277
+ enabled: true
278
+ )
279
+
280
+ # Direct queries
281
+ songs = Song.query(:plays.gt => 1000).results_direct
282
+ song = Song.query(title: "My Song").first_direct
283
+ count = Song.query.count_direct
284
+ ```
285
+
286
+ ## Cloud Functions
287
+
288
+ ```ruby
289
+ # Call a cloud function
290
+ result = Parse.call_function(:myFunction, { param1: "value" })
291
+
292
+ # Background job
293
+ Parse.trigger_job(:myJob, { data: "value" })
294
+ ```
295
+
296
+ ## Users & Authentication
297
+
298
+ ```ruby
299
+ # Signup — creates _User row, returns it with a session token.
300
+ user = Parse::User.signup("alice", "s3cret", "alice@example.com")
301
+ user.session_token # => "r:abc123..."
302
+
303
+ # Login — returns the user or nil on bad credentials.
304
+ user = Parse::User.login("alice", "s3cret")
305
+
306
+ # Resolve a user from a session token (e.g. from a Rails request).
307
+ user = Parse::User.session(request.headers["X-Parse-Session-Token"])
308
+ # session! raises Parse::Error::InvalidSessionTokenError on bad/expired tokens.
309
+
310
+ # Password reset email (configure email adapter on the server first).
311
+ Parse::User.request_password_reset("alice@example.com")
312
+ ```
313
+
314
+ When you have a session-token-authenticated user, pass it through to scope
315
+ queries and writes to that user's ACL. The query object exposes
316
+ `session_token=` as a setter; on `.all` / `.first` it's a constraint-hash
317
+ key. The class-level `.all_as` / `.first_as` helpers wrap it as a kwarg
318
+ when you'd rather not remember the spelling:
319
+
320
+ ```ruby
321
+ # Class-level kwarg form
322
+ Song.all_as(user, genre: "rock")
323
+ Song.first_as(user, genre: "rock")
324
+
325
+ # Constraints-hash form
326
+ Song.all(genre: "rock", session_token: user.session_token)
327
+
328
+ # Or block-scoped via Parse.with_session
329
+ Parse.with_session(user) do
330
+ Song.all(genre: "rock")
331
+ song.save
332
+ end
333
+
334
+ # Per-save kwarg
335
+ song.save(session_token: user.session_token)
336
+ ```
337
+
338
+ As of v5.0, `Parse::Query` no longer hard-codes `@use_master_key = true`
339
+ at init — the default is `nil` ("no caller preference") so the request
340
+ layer can apply `Parse.client_mode` and the `Parse.with_session` ambient
341
+ token cleanly. Server-mode (master key configured, no client_mode) still
342
+ sends the master key by default; this only matters if you've flipped
343
+ `Parse.client_mode = true` or are running inside a `with_session` block,
344
+ where the previous `true` default silently master-key-stamped queries.
345
+ Explicitly setting `use_master_key: true` (or `query.use_master_key = true`)
346
+ still forces the header. The mongo-direct routing gate treats a
347
+ configured master key on the client as an ambient credential in
348
+ server mode: direct-only constraints route through mongo-direct as
349
+ long as `Parse.client_mode` is false and `use_master_key` was not
350
+ explicitly set to `false`. The gate raises
351
+ `Parse::Query::MongoDirectRequired` for client-mode processes or
352
+ queries that opt out of the master key without supplying a
353
+ `session_token` / `.scope_to_user(user)` / `.scope_to_role(role)`.
354
+
355
+ ## Pointers, Relations, and Includes
356
+
357
+ Parse has three relationship shapes; pick by cardinality and access pattern:
358
+
359
+ ```ruby
360
+ class Song < Parse::Object
361
+ belongs_to :album # 1-to-1 pointer (column on Song)
362
+ has_many :comments # 1-to-many via inverse pointer on Comment
363
+ has_many :tags, through: :relation # many-to-many via _Join Parse Relation
364
+ end
365
+ ```
366
+
367
+ Pointers are **lazy by default** — `song.album` returns an unfetched
368
+ `Parse::Pointer`. Calling any property on it triggers a fetch, which causes
369
+ N+1 if you loop. Use `includes:` to batch them:
370
+
371
+ ```ruby
372
+ # BAD — one fetch per song
373
+ Song.all(limit: 50).each { |s| puts s.album.title }
374
+
375
+ # GOOD — single round-trip
376
+ Song.all(limit: 50, includes: [:album]).each { |s| puts s.album.title }
377
+
378
+ # Fetch the pointer explicitly when you need it later
379
+ song.album.fetch! unless song.album.pointer?
380
+ ```
381
+
382
+ For `through: :relation` columns, use the relation API rather than assigning
383
+ an array (Parse Server rejects bulk array writes to Relation columns):
384
+
385
+ ```ruby
386
+ tag = Tag.first_or_create!(name: "guitar")
387
+ song.tags.add(tag)
388
+ song.save
389
+ # Or, atomic (no read-modify-write):
390
+ song.op_add_relation!(:tags, tag)
391
+
392
+ # Querying the other side:
393
+ Song.query(tags: tag).results # songs containing this tag
394
+ tag.songs.results # inverse query, if Tag declares has_many :songs, through: :relation
395
+ ```
396
+
397
+ ## Atomic Operations
398
+
399
+ Use atomic ops to avoid read-modify-write races on counters, sets, and
400
+ relations. They go straight to the server as `$inc` / `$addToSet` / `$pull`
401
+ and don't require a `save` afterwards:
402
+
403
+ ```ruby
404
+ song.op_increment!(:plays) # +1
405
+ song.op_increment!(:plays, -1) # -1
406
+ song.op_add_unique!(:tags, ["live"]) # idempotent set-insert
407
+ song.op_remove!(:tags, ["demo"])
408
+ song.op_destroy!(:scratch_field) # unset
409
+ song.op_add_relation!(:contributors, user)
410
+ ```
411
+
412
+ ## Files
413
+
414
+ ```ruby
415
+ # From bytes
416
+ bytes = File.read("cover.jpg")
417
+ file = Parse::File.new("cover.jpg", bytes, "image/jpeg")
418
+ file.save # uploads, populates file.url
419
+
420
+ # From a URL (downloaded server-side, then uploaded)
421
+ file = Parse::File.new("https://example.com/cover.jpg")
422
+ file.save
423
+
424
+ # Attach to a property
425
+ class Song < Parse::Object
426
+ property :cover, :file
427
+ end
428
+
429
+ song.cover = file
430
+ song.save
431
+ song.cover.url # public URL on the Parse file storage
432
+ ```
433
+
434
+ ## GeoPoint Queries
435
+
436
+ ```ruby
437
+ class Place < Parse::Object
438
+ property :location, :geopoint
439
+ end
440
+
441
+ origin = Parse::GeoPoint.new(37.7749, -122.4194) # San Francisco
442
+
443
+ # Nearest first, capped at 5 km
444
+ Place.where(:location.near => origin.max_kilometers(5)).results
445
+
446
+ # Bounded box (SW corner, NE corner)
447
+ sw = Parse::GeoPoint.new(32.82, -117.23)
448
+ ne = Parse::GeoPoint.new(36.12, -115.31)
449
+ Place.where(:location.within_box => [sw, ne]).results
450
+
451
+ # Circle (does not sort by distance — cheaper than near + max_*)
452
+ Place.where(:location.within_sphere => [origin, 10, :km]).results
453
+
454
+ # Polygon (3+ points)
455
+ Place.where(:location.within_polygon => [pt1, pt2, pt3, pt4]).results
456
+ ```
457
+
458
+ ## Schema Migration
459
+
460
+ The SDK can push your local model definitions to the server so columns and
461
+ indexes match what `property` / `belongs_to` / `has_many` declare. Run this
462
+ once at boot or as a deploy step — without it, fields you declared in Ruby
463
+ won't exist on the server and `save` will silently drop them.
464
+
465
+ ```ruby
466
+ # One class
467
+ Song.auto_upgrade!
468
+
469
+ # Every Parse::Object subclass that has been loaded
470
+ Parse.auto_upgrade!
471
+
472
+ # Preview the diff before pushing
473
+ puts Parse::Schema.diff(Song).summary
474
+ Parse::Schema.migration(Song).apply!(dry_run: true)
475
+ ```
476
+
477
+ ## Webhooks (Cloud Code Triggers from Ruby)
478
+
479
+ Cloud Code triggers (`beforeSave`, `afterSave`, `beforeDelete`, `afterDelete`)
480
+ and custom functions can be implemented in Ruby and served as a Rack app that
481
+ Parse Server calls back into. **You must register the endpoint with the
482
+ server** — until you do, the trigger blocks below will not fire, even though
483
+ they're defined in Ruby.
484
+
485
+ ```ruby
486
+ class Song < Parse::Object
487
+ webhook :before_save do
488
+ # `self` is a Parse::Webhooks::Payload; `parse_object` is the row.
489
+ parse_object.title = parse_object.title.strip
490
+ parse_object # return the (possibly mutated) object
491
+ end
492
+
493
+ webhook :after_save do
494
+ Rails.logger.info("Saved song #{parse_object.id}")
495
+ end
496
+
497
+ webhook_function :recountPlays do
498
+ Song.find(params["songId"]).op_increment!(:plays, params["delta"].to_i)
499
+ end
500
+ end
501
+
502
+ # Mount the Rack app (in config.ru or a Rails route):
503
+ run Parse::Webhooks
504
+
505
+ # Tell Parse Server where to reach it. Do this once per deploy.
506
+ Parse::Webhooks.register_triggers!("https://your-app.example.com/webhooks")
507
+ Parse::Webhooks.register_functions!("https://your-app.example.com/webhooks")
508
+ ```
509
+
510
+ The endpoint must be HTTPS and publicly reachable from Parse Server. Set
511
+ `Parse::Webhooks.key = ENV["PARSE_WEBHOOK_KEY"]` and configure the same key
512
+ on Parse Server to authenticate incoming trigger calls.
513
+
514
+ ## Analytics
515
+
516
+ Parse Server exposes a single analytics endpoint, `POST /events/<name>`. The
517
+ gem wraps it as `Parse.track_event`. Dimensions are passed via the
518
+ `dimensions:` keyword — loose symbol arguments would be absorbed by the
519
+ forwarded `**opts` splat under Ruby 3 keyword separation and would never
520
+ reach Parse Server.
521
+
522
+ ```ruby
523
+ # Custom event with dimensions
524
+ Parse.track_event("post_viewed", dimensions: { source: "feed", workspace: "w1" })
525
+
526
+ # Parse's conventional app-launch event
527
+ Parse.track_event("AppOpened")
528
+
529
+ # Error tracking
530
+ Parse.track_event("error", dimensions: { code: "E_RATE_LIMIT" })
531
+ ```
532
+
533
+ The call is a blocking HTTP POST — wrap in a thread or background job if you
534
+ don't want it on the request path.
535
+
536
+ **Reading events back:** Parse Server's default `analyticsAdapter` is a no-op:
537
+ events POSTed to `/events` are accepted but neither persisted nor queryable
538
+ through the SDK. (Operators who wire a custom adapter decide what to do with
539
+ each event. The legacy parse.com eight-dimension cap does NOT apply to Parse
540
+ Server out of the box; if a cap matters to you, your adapter enforces it.)
541
+
542
+ If you need to query analytics, persist them to a regular `Parse::Object`
543
+ subclass yourself:
544
+
545
+ ```ruby
546
+ class AnalyticsEvent < Parse::Object
547
+ property :name, :string, required: true
548
+ property :dimensions, :object
549
+ property :occurred_at, :date
550
+ end
551
+
552
+ AnalyticsEvent.create(name: "post_viewed",
553
+ dimensions: { source: "feed" },
554
+ occurred_at: Time.now)
555
+
556
+ # Aggregation is on the query, not the class
557
+ AnalyticsEvent.query.group_by(:name).count
558
+ AnalyticsEvent.query.group_by_date(:occurred_at, :day).count
559
+ ```
560
+
561
+ That gives you the full query, aggregation, ACL, and mongo-direct surface for
562
+ analytics data — at the cost of an extra row write per event.
563
+
564
+ ## Error Handling
565
+
566
+ ```ruby
567
+ begin
568
+ song.save!
569
+ rescue Parse::RecordNotSaved => e
570
+ puts "Save failed: #{e.message}"
571
+ end
572
+
573
+ # Or check return value
574
+ if song.save
575
+ puts "Saved!"
576
+ else
577
+ puts "Errors: #{song.errors.full_messages}"
578
+ end
579
+ ```
580
+
581
+ ## More Information
582
+
583
+ - [CHANGELOG](./CHANGELOG.md) - Full feature history
584
+ - [GitHub Releases](https://github.com/neurosynq/parse-stack-next/releases) - Release notes
585
+ - [Parse Server Docs](https://docs.parseplatform.org) - Parse Server documentation