parse-stack-next 5.5.6 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +634 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +17 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/binding_audit.rb +211 -0
  54. data/lib/parse/embeddings/cache.rb +17 -17
  55. data/lib/parse/embeddings/cohere.rb +21 -22
  56. data/lib/parse/embeddings/image_fetch.rb +22 -21
  57. data/lib/parse/embeddings/jina.rb +21 -21
  58. data/lib/parse/embeddings/local_http.rb +6 -7
  59. data/lib/parse/embeddings/media_file.rb +137 -0
  60. data/lib/parse/embeddings/openai.rb +5 -5
  61. data/lib/parse/embeddings/provider.rb +40 -1
  62. data/lib/parse/embeddings/qwen.rb +10 -10
  63. data/lib/parse/embeddings/spend_cap.rb +2 -3
  64. data/lib/parse/embeddings/streaming_body.rb +170 -0
  65. data/lib/parse/embeddings/video_source.rb +121 -0
  66. data/lib/parse/embeddings/voyage.rb +554 -143
  67. data/lib/parse/embeddings.rb +83 -27
  68. data/lib/parse/graphql/type_generator.rb +1 -1
  69. data/lib/parse/graphql.rb +5 -5
  70. data/lib/parse/live_query/client.rb +1 -1
  71. data/lib/parse/live_query.rb +1 -1
  72. data/lib/parse/lock.rb +15 -18
  73. data/lib/parse/lock_backend.rb +1 -1
  74. data/lib/parse/lookup_rewriter.rb +6 -6
  75. data/lib/parse/model/classes/role.rb +227 -28
  76. data/lib/parse/model/classes/user.rb +60 -1
  77. data/lib/parse/model/clp.rb +4 -4
  78. data/lib/parse/model/core/actions.rb +97 -15
  79. data/lib/parse/model/core/create_lock.rb +0 -2
  80. data/lib/parse/model/core/describe.rb +57 -57
  81. data/lib/parse/model/core/embed_managed.rb +43 -19
  82. data/lib/parse/model/core/fetching.rb +2 -0
  83. data/lib/parse/model/core/indexing.rb +14 -14
  84. data/lib/parse/model/core/parse_reference.rb +1 -1
  85. data/lib/parse/model/core/properties.rb +39 -3
  86. data/lib/parse/model/core/querying.rb +1 -1
  87. data/lib/parse/model/core/schema.rb +2 -2
  88. data/lib/parse/model/core/search_indexing.rb +2 -2
  89. data/lib/parse/model/core/vector_searchable.rb +34 -8
  90. data/lib/parse/model/file.rb +21 -15
  91. data/lib/parse/model/geojson.rb +2 -2
  92. data/lib/parse/model/geopoint.rb +1 -0
  93. data/lib/parse/model/object.rb +132 -66
  94. data/lib/parse/model/pointer.rb +2 -0
  95. data/lib/parse/model/polygon.rb +3 -6
  96. data/lib/parse/model/push.rb +2 -2
  97. data/lib/parse/model/vector.rb +3 -1
  98. data/lib/parse/mongodb.rb +512 -282
  99. data/lib/parse/pipeline_security.rb +3 -0
  100. data/lib/parse/query/constraints.rb +46 -46
  101. data/lib/parse/query.rb +146 -59
  102. data/lib/parse/retrieval/agent_tool.rb +34 -20
  103. data/lib/parse/retrieval/chunk.rb +1 -0
  104. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  105. data/lib/parse/retrieval/reranker.rb +3 -4
  106. data/lib/parse/retrieval/retriever.rb +4 -6
  107. data/lib/parse/schema/index_migrator.rb +26 -26
  108. data/lib/parse/schema/search_index_migrator.rb +19 -19
  109. data/lib/parse/stack/tasks.rb +6 -6
  110. data/lib/parse/stack/version.rb +1 -1
  111. data/lib/parse/stack.rb +10 -10
  112. data/lib/parse/vector_search/hybrid.rb +166 -33
  113. data/lib/parse/vector_search.rb +141 -13
  114. data/lib/parse/webhooks/payload.rb +1 -0
  115. data/lib/parse/webhooks/registration.rb +4 -4
  116. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  117. data/lib/parse/webhooks.rb +22 -4
  118. data/parse-stack-next.gemspec +6 -6
  119. metadata +14 -1
@@ -95,6 +95,44 @@ module Parse
95
95
  # one. Atlas's guidance: numCandidates ≥ 10 × limit, ≤ 10_000.
96
96
  DEFAULT_NUM_CANDIDATES_MULTIPLIER = 20
97
97
 
98
+ # How far past `k` the `$vectorSearch.limit` is raised when
99
+ # something downstream can drop rows (ACL enforcement, a
100
+ # caller-supplied `filter`, `protectedFields`, or pointer-field
101
+ # filtering). Atlas applies `limit` BEFORE any of those run, so
102
+ # without overfetching a caller who can read 2 of the top 10 asks
103
+ # for 10 and receives 2 — even when hundreds of readable matches
104
+ # exist further down the ranking.
105
+ #
106
+ # This is a mitigation, not a guarantee: a sufficiently selective
107
+ # ACL can still exhaust any finite candidate window. Deterministic
108
+ # fill would require `_rperm` declared as `type: "filter"` in the
109
+ # Atlas index (so the prefilter enforces visibility) or iterative
110
+ # candidate expansion. The instrumentation emitted by {.search}
111
+ # exists so an underfill is observable rather than silent.
112
+ DEFAULT_CANDIDATE_MULTIPLIER = 10
113
+
114
+ # Ceiling on the internally-raised candidate window. Atlas caps
115
+ # `numCandidates` at 10_000 and `numCandidates` must be >= `limit`,
116
+ # so the candidate window cannot usefully exceed that.
117
+ MAX_CANDIDATE_LIMIT = 10_000
118
+
119
+ # Emitted once per {.search}. The counts are deliberately named for
120
+ # where they are actually measured — there is no cheap way to learn
121
+ # how many rows `$vectorSearch` emitted before the server-side
122
+ # `$match` stages ran, so no field claims to be that number:
123
+ #
124
+ # * `candidate_limit` / `num_candidates` — the requested window.
125
+ # * `post_filter_count` — rows returned by the pipeline, i.e. after
126
+ # the server-side ACL `$match` and any caller `filter`.
127
+ # * `post_pointer_count` — rows left after client-side redaction and
128
+ # pointer-field filtering.
129
+ # * `returned_count` — rows handed back, after trimming to `k`.
130
+ # * `underfilled` — the caller received fewer than `k`.
131
+ #
132
+ # Obtaining a true pre-`$match` count would require a `$facet`, at
133
+ # the cost of a second pass over the candidate set.
134
+ AS_NOTIFICATION_NAME = "parse.vector_search.search"
135
+
98
136
  # Accepted {.index_drift_policy} values.
99
137
  INDEX_DRIFT_POLICIES = %i[warn raise ignore].freeze
100
138
 
@@ -130,6 +168,7 @@ module Parse
130
168
  def index_drift_policy
131
169
  @index_drift_policy ||= :warn
132
170
  end
171
+
133
172
  # Low-level `$vectorSearch` entry point.
134
173
  #
135
174
  # @param collection_name [String] Parse class name / Mongo
@@ -165,8 +204,9 @@ module Parse
165
204
  # `_vscore` rather than `_score` so hybrid pipelines with
166
205
  # Atlas Search don't collide on the same key).
167
206
  def search(collection_name, field:, query_vector:, k: 10,
168
- num_candidates: nil, filter: nil, vector_filter: nil,
169
- index: nil, max_time_ms: nil, **scope_opts)
207
+ num_candidates: nil, candidate_limit: nil, filter: nil,
208
+ vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts)
209
+ candidate_limit_override = candidate_limit
170
210
  require_available!
171
211
  index_name = (index || @default_index)
172
212
  if index_name.nil? || index_name.to_s.empty?
@@ -195,12 +235,61 @@ module Parse
195
235
  raise ArgumentError, "k must be in 1..#{MAX_K} (got #{k_int})."
196
236
  end
197
237
 
198
- num_candidates_int = (num_candidates || (k_int * DEFAULT_NUM_CANDIDATES_MULTIPLIER)).to_i
238
+ # Anything that can drop rows AFTER Atlas has already applied
239
+ # `limit` forces an overfetch, otherwise the caller silently
240
+ # receives fewer than `k`. Master mode with no caller filter
241
+ # has no attrition, so it keeps the old one-for-one cost.
242
+ attrition_possible = !resolution.master? || !(filter.nil? || filter.empty?)
243
+ candidate_limit = if candidate_limit_override
244
+ Integer(candidate_limit_override)
245
+ elsif attrition_possible
246
+ [k_int * DEFAULT_CANDIDATE_MULTIPLIER, MAX_CANDIDATE_LIMIT].min
247
+ else
248
+ k_int
249
+ end
250
+ if candidate_limit < k_int
251
+ raise ArgumentError,
252
+ "candidate_limit (#{candidate_limit}) must be >= k (#{k_int})."
253
+ end
254
+ if candidate_limit > MAX_CANDIDATE_LIMIT
255
+ raise ArgumentError,
256
+ "candidate_limit capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{candidate_limit})."
257
+ end
258
+
259
+ # ANN width stays anchored to `k`, NOT to the raised window.
260
+ # Deriving it from `candidate_limit` would multiply twice and
261
+ # silently widen the HNSW search by the candidate multiplier —
262
+ # a scoped k=10 would jump from 200 to 2000 candidates. The
263
+ # window only needs numCandidates to be at least as large as
264
+ # `limit`, so take whichever is greater.
265
+ derived_num_candidates =
266
+ [k_int * DEFAULT_NUM_CANDIDATES_MULTIPLIER, candidate_limit].max
267
+ num_candidates_int = (num_candidates || derived_num_candidates).to_i
268
+ num_candidates_int = MAX_CANDIDATE_LIMIT if num_candidates_int > MAX_CANDIDATE_LIMIT &&
269
+ num_candidates.nil?
270
+
271
+ # A caller-supplied num_candidates that predates the candidate
272
+ # window used to be valid whenever it was >= k. Clamping the
273
+ # implicit window down to it keeps those calls working rather
274
+ # than turning them into a new ArgumentError. An EXPLICIT
275
+ # candidate_limit still conflicts loudly — that combination can
276
+ # only be a mistake.
277
+ if num_candidates && num_candidates_int < candidate_limit
278
+ if candidate_limit_override
279
+ raise ArgumentError,
280
+ "num_candidates (#{num_candidates_int}) must be >= candidate_limit " \
281
+ "(#{candidate_limit})."
282
+ end
283
+ candidate_limit = [num_candidates_int, k_int].max
284
+ end
285
+
199
286
  if num_candidates_int < k_int
200
- raise ArgumentError, "num_candidates (#{num_candidates_int}) must be >= k (#{k_int})."
287
+ raise ArgumentError,
288
+ "num_candidates (#{num_candidates_int}) must be >= k (#{k_int})."
201
289
  end
202
- if num_candidates_int > 10_000
203
- raise ArgumentError, "num_candidates capped at 10000 by Atlas (got #{num_candidates_int})."
290
+ if num_candidates_int > MAX_CANDIDATE_LIMIT
291
+ raise ArgumentError,
292
+ "num_candidates capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{num_candidates_int})."
204
293
  end
205
294
 
206
295
  validated_vector = validate_query_vector!(query_vector)
@@ -219,11 +308,13 @@ module Parse
219
308
  )
220
309
 
221
310
  vs_stage = {
222
- "index" => index_name.to_s,
223
- "path" => path,
224
- "queryVector" => validated_vector,
311
+ "index" => index_name.to_s,
312
+ "path" => path,
313
+ "queryVector" => validated_vector,
225
314
  "numCandidates" => num_candidates_int,
226
- "limit" => k_int,
315
+ # Deliberately the raised candidate window, not `k` — the
316
+ # result set is trimmed to `k` after enforcement runs.
317
+ "limit" => candidate_limit,
227
318
  }
228
319
  vs_stage["filter"] = vector_filter if vector_filter && !vector_filter.empty?
229
320
  pipeline = [{ "$vectorSearch" => vs_stage }]
@@ -247,7 +338,11 @@ module Parse
247
338
 
248
339
  pipeline << { "$match" => filter } if filter
249
340
 
250
- raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms)
341
+ raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms,
342
+ authorizing_client: Parse::ACLScope.client_of(resolution))
343
+ # Already past the server-side ACL `$match` and any caller
344
+ # `filter` — NOT the number $vectorSearch emitted.
345
+ post_filter_count = raw_results.length
251
346
 
252
347
  # Post-fetch enforcement: walk the rows the same way
253
348
  # Parse::MongoDB.aggregate would. Master mode skips every
@@ -266,9 +361,40 @@ module Parse
266
361
  # every mode, master included, so `_hashed_password` /
267
362
  # `_session_token` can never surface through this entry point.
268
363
  raw_results.map! { |doc| Parse::PipelineSecurity.strip_internal_fields(doc) }
364
+
365
+ # Trim only AFTER every enforcement layer has run — that
366
+ # ordering is the whole point of the raised candidate window.
367
+ post_pointer_count = raw_results.length
368
+ raw_results = raw_results.first(k_int) if post_pointer_count > k_int
369
+
370
+ emit_search_stats(
371
+ collection_name: collection_name, k: k_int,
372
+ candidate_limit: candidate_limit, num_candidates: num_candidates_int,
373
+ post_filter_count: post_filter_count, post_pointer_count: post_pointer_count,
374
+ returned_count: raw_results.length, master: resolution.master?,
375
+ )
376
+
269
377
  raw_results
270
378
  end
271
379
 
380
+ # @!visibility private
381
+ # Emit per-search counts so ACL/filter attrition is observable.
382
+ # `underfilled` means the caller received fewer rows than they
383
+ # asked for — the signal worth alerting on, since it means either
384
+ # the candidate window was too small for this principal or the
385
+ # collection simply ran out of matches. Distinguishing those two
386
+ # would need a pre-`$match` count, which this deliberately does
387
+ # not fabricate.
388
+ def emit_search_stats(**payload)
389
+ return unless defined?(ActiveSupport::Notifications)
390
+
391
+ payload[:pointer_attrition] =
392
+ payload[:post_filter_count] - payload[:post_pointer_count]
393
+ payload[:underfilled] = payload[:returned_count] < payload[:k]
394
+ ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload)
395
+ nil
396
+ end
397
+
272
398
  # Validate a query vector. Public so callers (and tests) can
273
399
  # invoke it independently of {.search}.
274
400
  #
@@ -359,10 +485,12 @@ module Parse
359
485
  # `Parse::MongoDB.aggregate` because that helper prepends an
360
486
  # ACL `$match` at stage 0, which Atlas rejects for any pipeline
361
487
  # whose stage 0 is `$vectorSearch`.
362
- def run_pipeline!(collection_name, pipeline, max_time_ms: nil)
488
+ def run_pipeline!(collection_name, pipeline, max_time_ms: nil, authorizing_client: nil)
363
489
  agg_opts = {}
364
490
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
365
- coll = Parse::MongoDB.collection(collection_name)
491
+ # Vector search bypasses Parse::MongoDB.aggregate, so the binding
492
+ # guard only sees this read if the authorizing client arrives here.
493
+ coll = Parse::MongoDB.collection(collection_name, authorizing_client: authorizing_client)
366
494
  coll.aggregate(pipeline, agg_opts).to_a
367
495
  rescue => e
368
496
  Parse::MongoDB.send(:raise_if_timeout!, e, collection_name, max_time_ms)
@@ -718,6 +718,7 @@ module Parse
718
718
  @deferred_callbacks << block
719
719
  true
720
720
  end
721
+
721
722
  alias_method :defer, :after_response
722
723
 
723
724
  # @!visibility private
@@ -33,10 +33,10 @@ module Parse
33
33
  def assert_webhook_url_safe!(url)
34
34
  raise ArgumentError, "Webhook URL is required" if url.nil? || url.to_s.empty?
35
35
  uri = begin
36
- URI.parse(url.to_s)
37
- rescue URI::InvalidURIError => e
38
- raise ArgumentError, "Invalid webhook URL: #{e.message}"
39
- end
36
+ URI.parse(url.to_s)
37
+ rescue URI::InvalidURIError => e
38
+ raise ArgumentError, "Invalid webhook URL: #{e.message}"
39
+ end
40
40
  unless %w[http https].include?(uri.scheme)
41
41
  raise ArgumentError, "Webhook URL must be http(s) (got #{uri.scheme.inspect})"
42
42
  end
@@ -51,12 +51,12 @@ module Parse
51
51
  # inside the save handler (Parse Server has no create trigger); the webhook
52
52
  # router runs the destroy chain inside the beforeDelete handler.
53
53
  CALLBACK_TRIGGER_MAP = {
54
- [:save, :before] => :before_save,
55
- [:create, :before] => :before_save,
56
- [:save, :after] => :after_save,
57
- [:create, :after] => :after_save,
54
+ [:save, :before] => :before_save,
55
+ [:create, :before] => :before_save,
56
+ [:save, :after] => :after_save,
57
+ [:create, :after] => :after_save,
58
58
  [:destroy, :before] => :before_delete,
59
- [:destroy, :after] => :after_delete,
59
+ [:destroy, :after] => :after_delete,
60
60
  }.freeze
61
61
 
62
62
  # ActiveModel callback chains + phases with NO server trigger that can run
@@ -67,10 +67,10 @@ module Parse
67
67
  # trigger registration changes that. Surfaced as an informational note, not
68
68
  # a fixable gap.
69
69
  LOCAL_ONLY_MAP = {
70
- [:update, :before] => :before_update,
71
- [:update, :after] => :after_update,
70
+ [:update, :before] => :before_update,
71
+ [:update, :after] => :after_update,
72
72
  [:validation, :before] => :before_validation,
73
- [:validation, :after] => :after_validation,
73
+ [:validation, :after] => :after_validation,
74
74
  }.freeze
75
75
 
76
76
  # The ActiveModel callback chains we introspect.
@@ -104,12 +104,12 @@ module Parse
104
104
 
105
105
  def initialize(parse_class:, callbacks:, local_routes:, server_triggers:,
106
106
  findings:, modeled:)
107
- @parse_class = parse_class
108
- @callbacks = callbacks
109
- @local_routes = local_routes
107
+ @parse_class = parse_class
108
+ @callbacks = callbacks
109
+ @local_routes = local_routes
110
110
  @server_triggers = server_triggers
111
- @findings = findings
112
- @modeled = modeled
111
+ @findings = findings
112
+ @modeled = modeled
113
113
  end
114
114
 
115
115
  # @return [Boolean] true when the class has at least one finding.
@@ -120,12 +120,12 @@ module Parse
120
120
  # @return [Hash] a JSON-safe representation of this row.
121
121
  def to_h
122
122
  {
123
- parse_class: parse_class,
124
- modeled: modeled,
125
- callbacks: callbacks,
126
- local_routes: local_routes,
123
+ parse_class: parse_class,
124
+ modeled: modeled,
125
+ callbacks: callbacks,
126
+ local_routes: local_routes,
127
127
  server_triggers: server_triggers,
128
- findings: findings,
128
+ findings: findings,
129
129
  }
130
130
  end
131
131
  end
@@ -144,11 +144,11 @@ module Parse
144
144
  # callbacks (e.g. the `_User` default-ACL callback). Off by default to keep
145
145
  # the report focused on app-defined logic.
146
146
  def initialize(network: true, client: nil, include_framework: false)
147
- @networked = network
147
+ @networked = network
148
148
  @include_framework = include_framework
149
- @client = client
150
- @server_lookup = network ? fetch_server_triggers : {}
151
- @classes = build_classes
149
+ @client = client
150
+ @server_lookup = network ? fetch_server_triggers : {}
151
+ @classes = build_classes
152
152
  end
153
153
 
154
154
  # @return [Array<Hash>] every finding across all classes, flattened, with the
@@ -164,10 +164,11 @@ module Parse
164
164
  def to_h
165
165
  {
166
166
  networked: networked,
167
- classes: @classes.map(&:to_h),
168
- summary: summary,
167
+ classes: @classes.map(&:to_h),
168
+ summary: summary,
169
169
  }
170
170
  end
171
+
171
172
  alias as_json to_h
172
173
 
173
174
  # @return [Hash] finding counts keyed by kind, plus class totals.
@@ -175,9 +176,9 @@ module Parse
175
176
  counts = Hash.new(0)
176
177
  gaps.each { |g| counts[g[:kind]] += 1 }
177
178
  {
178
- classes_audited: @classes.size,
179
+ classes_audited: @classes.size,
179
180
  classes_with_issues: @classes.count(&:issues?),
180
- findings: counts,
181
+ findings: counts,
181
182
  }
182
183
  end
183
184
 
@@ -212,6 +213,7 @@ module Parse
212
213
  s[:findings].sort.each { |kind, n| lines << " #{kind}: #{n}" }
213
214
  lines.join("\n")
214
215
  end
216
+
215
217
  alias to_s pretty
216
218
 
217
219
  private
@@ -231,7 +233,7 @@ module Parse
231
233
  lookup = Hash.new { |h, k| h[k] = {} }
232
234
  client.triggers.results.each do |t|
233
235
  next unless t["url"].present?
234
- name = t["triggerName"]
236
+ name = t["triggerName"]
235
237
  klass = t[Parse::Model::KEY_CLASS_NAME] || t["className"]
236
238
  next if name.blank? || klass.blank?
237
239
  lookup[klass.to_s][name.to_s.underscore.to_sym] = t["url"]
@@ -262,18 +264,18 @@ module Parse
262
264
  end
263
265
 
264
266
  def audit_class(name)
265
- klass = name == "*" ? nil : (Parse::Model.find_class(name) rescue nil)
267
+ klass = name == "*" ? nil : (Parse::Model.find_class(name) rescue nil)
266
268
  callbacks = klass ? collect_callbacks(klass) : {}
267
- routes = collect_local_routes(name)
268
- server = @server_lookup[name] || {}
269
- findings = analyze(name, callbacks, routes, server)
269
+ routes = collect_local_routes(name)
270
+ server = @server_lookup[name] || {}
271
+ findings = analyze(name, callbacks, routes, server)
270
272
  ClassAudit.new(
271
- parse_class: name,
272
- callbacks: callbacks,
273
- local_routes: routes,
273
+ parse_class: name,
274
+ callbacks: callbacks,
275
+ local_routes: routes,
274
276
  server_triggers: server,
275
- findings: findings,
276
- modeled: !klass.nil?,
277
+ findings: findings,
278
+ modeled: !klass.nil?,
277
279
  )
278
280
  end
279
281
 
@@ -373,7 +375,7 @@ module Parse
373
375
  end
374
376
 
375
377
  required.each do |trigger, cb_keys|
376
- has_route = routes.include?(trigger)
378
+ has_route = routes.include?(trigger)
377
379
  has_server = server.key?(trigger)
378
380
  missing = []
379
381
  missing << :route unless has_route
@@ -381,7 +383,7 @@ module Parse
381
383
  next if missing.empty?
382
384
 
383
385
  findings << {
384
- kind: :callbacks_inert,
386
+ kind: :callbacks_inert,
385
387
  trigger: trigger,
386
388
  missing: missing,
387
389
  callbacks: cb_keys.sort,
@@ -398,7 +400,7 @@ module Parse
398
400
  # Wildcard-only coverage is reported on the "*" row, not here.
399
401
  next unless Parse::Webhooks.routes[trigger]&.key?(name)
400
402
  findings << {
401
- kind: :route_not_registered,
403
+ kind: :route_not_registered,
402
404
  trigger: trigger,
403
405
  message: "Local `webhook :#{trigger}` block for #{name} is not " \
404
406
  "registered as a server trigger — run register_triggers! " \
@@ -410,7 +412,7 @@ module Parse
410
412
  server.each_key do |trigger|
411
413
  next if routes.include?(trigger)
412
414
  findings << {
413
- kind: :orphan_server_trigger,
415
+ kind: :orphan_server_trigger,
414
416
  trigger: trigger,
415
417
  message: "Server trigger #{trigger} is registered for #{name} but no " \
416
418
  "local webhook block handles it — every matching operation " \
@@ -426,7 +428,7 @@ module Parse
426
428
  end
427
429
  if local_only.any?
428
430
  findings << {
429
- kind: :local_only_callbacks,
431
+ kind: :local_only_callbacks,
430
432
  callbacks: local_only.sort,
431
433
  message: "#{name} has local-only callbacks (#{local_only.sort.join(", ")}) " \
432
434
  "that no server trigger can run — they fire for Ruby-initiated " \
@@ -451,25 +453,24 @@ module Parse
451
453
 
452
454
  def inert_message(name, trigger, missing, cb_keys)
453
455
  callbacks = cb_keys.sort.join(", ")
454
- reason =
455
- if missing == [:route, :server]
456
+ reason = if missing == [:route, :server]
456
457
  "neither a local `webhook :#{trigger}` block nor a server trigger is " \
457
- "registered"
458
+ "registered"
458
459
  elsif missing == [:route]
459
460
  "no local `webhook :#{trigger}` block is registered to handle it"
460
461
  else # [:server]
461
462
  "a local block exists but the #{trigger} server trigger is not registered"
462
463
  end
463
464
  "#{name} callbacks (#{callbacks}) will NOT run for non-Ruby clients: " \
464
- "#{reason}. Register `webhook :#{trigger}` and run register_triggers!."
465
+ "#{reason}. Register `webhook :#{trigger}` and run register_triggers!."
465
466
  end
466
467
 
467
468
  def finding_glyph(kind)
468
469
  case kind
469
- when :callbacks_inert then "GAP "
470
- when :route_not_registered then "GAP "
470
+ when :callbacks_inert then "GAP "
471
+ when :route_not_registered then "GAP "
471
472
  when :orphan_server_trigger then "WARN"
472
- when :local_only_callbacks then "note"
473
+ when :local_only_callbacks then "note"
473
474
  else " "
474
475
  end
475
476
  end
@@ -493,7 +494,7 @@ module Parse
493
494
  # `pretty: true`.
494
495
  def trigger_audit(pretty: false, network: true, client: nil, include_framework: false)
495
496
  audit = TriggerAudit.new(
496
- network: network, client: client, include_framework: include_framework
497
+ network: network, client: client, include_framework: include_framework,
497
498
  )
498
499
  pretty ? audit.pretty : audit.to_h
499
500
  end
@@ -182,8 +182,26 @@ module Parse
182
182
  raise ArgumentError, "Invalid Webhook registration trigger #{type} #{className}"
183
183
  end
184
184
 
185
- # AfterSave/AfterDelete hooks support more than one
186
- if type == :after_save || type == :after_delete
185
+ # Triggers whose handlers compose instead of replacing one another.
186
+ #
187
+ # `after_save` / `after_delete` have always accumulated. The remaining
188
+ # `after_*` triggers are added because the SDK itself now registers
189
+ # them for cache invalidation: without this, registering an internal
190
+ # `after_logout` handler would silently replace an application's own,
191
+ # with the winner decided by file load order and no warning.
192
+ #
193
+ # This is safe only for non-rejectable triggers. Parse Server ignores
194
+ # their response body, and {#call_route} normalizes their result to
195
+ # `true` regardless, so `.last` semantics are irrelevant.
196
+ # {REJECTABLE_NON_OBJECT_TRIGGERS} are deliberately excluded: a
197
+ # composite of those must deny if ANY handler denies, and folding with
198
+ # `.last` would discard an earlier rejection.
199
+ composable = type == :after_save || type == :after_delete ||
200
+ (NON_OBJECT_TRIGGERS.include?(type) &&
201
+ !REJECTABLE_NON_OBJECT_TRIGGERS.include?(type) &&
202
+ type.to_s.start_with?("after_"))
203
+
204
+ if composable
187
205
  routes[type][className] ||= []
188
206
  routes[type][className].push block
189
207
  else
@@ -353,7 +371,7 @@ module Parse
353
371
  if pre_obj.respond_to?(:apply_field_guards!)
354
372
  pre_obj.apply_field_guards!(
355
373
  master: payload.master? || false,
356
- is_new: payload.original.blank?
374
+ is_new: payload.original.blank?,
357
375
  )
358
376
  end
359
377
  end
@@ -777,7 +795,7 @@ module Parse
777
795
  else
778
796
  if self.logging.present?
779
797
  puts "[Webhooks] --> Could not find mapping route for " \
780
- "#{Parse::Middleware::BodyBuilder.redact(payload.to_json)}"
798
+ "#{Parse::Middleware::BodyBuilder.redact(payload.to_json)}"
781
799
  end
782
800
  end
783
801
 
@@ -6,7 +6,7 @@ require "parse/stack/version"
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "parse-stack-next"
8
8
  spec.version = Parse::Stack::VERSION
9
- spec.authors = ["Adrian Curtin", "Anthony Persaud", "Henry Spindell"]
9
+ spec.authors = ["Adrian Curtin", "Anthony Persaud", "Henry Spindell"]
10
10
  spec.email = ["adrian+parse-stack@neurosynq.net"]
11
11
 
12
12
  spec.summary = %q{Parse Server SDK for Ruby — ORM, queries, auth, and MongoDB-direct access}
@@ -15,11 +15,11 @@ Gem::Specification.new do |spec|
15
15
  spec.license = "MIT"
16
16
 
17
17
  spec.metadata = {
18
- "homepage_uri" => "https://github.com/neurosynq/parse-stack-next",
19
- "source_code_uri" => "https://github.com/neurosynq/parse-stack-next",
20
- "changelog_uri" => "https://github.com/neurosynq/parse-stack-next/blob/main/CHANGELOG.md",
21
- "bug_tracker_uri" => "https://github.com/neurosynq/parse-stack-next/issues",
22
- "documentation_uri" => "https://neurosynq.github.io/parse-stack-next/",
18
+ "homepage_uri" => "https://github.com/neurosynq/parse-stack-next",
19
+ "source_code_uri" => "https://github.com/neurosynq/parse-stack-next",
20
+ "changelog_uri" => "https://github.com/neurosynq/parse-stack-next/blob/main/CHANGELOG.md",
21
+ "bug_tracker_uri" => "https://github.com/neurosynq/parse-stack-next/issues",
22
+ "documentation_uri" => "https://neurosynq.github.io/parse-stack-next/",
23
23
  "rubygems_mfa_required" => "true",
24
24
  }
25
25
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.5.6
4
+ version: 5.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -225,6 +225,7 @@ files:
225
225
  - docs/_config.yml
226
226
  - docs/acl_clp_guide.md
227
227
  - docs/atlas_vector_search_guide.md
228
+ - docs/caching.md
228
229
  - docs/client_sdk_guide.md
229
230
  - docs/mcp_guide.md
230
231
  - docs/mongodb_direct_guide.md
@@ -242,6 +243,7 @@ files:
242
243
  - examples/webhook_server.rb
243
244
  - lib/parse-stack-next.rb
244
245
  - lib/parse-stack.rb
246
+ - lib/parse/access.rb
245
247
  - lib/parse/acl_scope.rb
246
248
  - lib/parse/agent.rb
247
249
  - lib/parse/agent/approval_gate.rb
@@ -284,8 +286,15 @@ files:
284
286
  - lib/parse/atlas_search/result.rb
285
287
  - lib/parse/atlas_search/search_builder.rb
286
288
  - lib/parse/atlas_search/session.rb
289
+ - lib/parse/authorization.rb
290
+ - lib/parse/cache/invalidation.rb
291
+ - lib/parse/cache/keyspace.rb
292
+ - lib/parse/cache/moneta_surface.rb
287
293
  - lib/parse/cache/pool.rb
288
294
  - lib/parse/cache/redis.rb
295
+ - lib/parse/cache/scoped_view.rb
296
+ - lib/parse/cache/sub_cache.rb
297
+ - lib/parse/cache/upstream_roles.rb
289
298
  - lib/parse/client.rb
290
299
  - lib/parse/client/authentication.rb
291
300
  - lib/parse/client/batch.rb
@@ -301,16 +310,20 @@ files:
301
310
  - lib/parse/console.rb
302
311
  - lib/parse/embeddings.rb
303
312
  - lib/parse/embeddings/batch_embedder.rb
313
+ - lib/parse/embeddings/binding_audit.rb
304
314
  - lib/parse/embeddings/cache.rb
305
315
  - lib/parse/embeddings/cohere.rb
306
316
  - lib/parse/embeddings/fixture.rb
307
317
  - lib/parse/embeddings/image_fetch.rb
308
318
  - lib/parse/embeddings/jina.rb
309
319
  - lib/parse/embeddings/local_http.rb
320
+ - lib/parse/embeddings/media_file.rb
310
321
  - lib/parse/embeddings/openai.rb
311
322
  - lib/parse/embeddings/provider.rb
312
323
  - lib/parse/embeddings/qwen.rb
313
324
  - lib/parse/embeddings/spend_cap.rb
325
+ - lib/parse/embeddings/streaming_body.rb
326
+ - lib/parse/embeddings/video_source.rb
314
327
  - lib/parse/embeddings/voyage.rb
315
328
  - lib/parse/graphql.rb
316
329
  - lib/parse/graphql/scalars.rb