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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- 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
|
|
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("
|
|
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
|
|
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
|
|
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
|
data/docs/usage_guide.md
ADDED
|
@@ -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
|