parse-stack-next 5.0.1 → 5.1.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +792 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/classes/audience.rb +5 -0
  37. data/lib/parse/model/classes/installation.rb +122 -0
  38. data/lib/parse/model/classes/job_schedule.rb +3 -1
  39. data/lib/parse/model/classes/job_status.rb +4 -1
  40. data/lib/parse/model/classes/push_status.rb +4 -1
  41. data/lib/parse/model/classes/session.rb +7 -0
  42. data/lib/parse/model/classes/user.rb +204 -0
  43. data/lib/parse/model/core/create_lock.rb +28 -146
  44. data/lib/parse/model/core/embed_managed.rb +162 -13
  45. data/lib/parse/model/core/parse_reference.rb +17 -1
  46. data/lib/parse/model/core/querying.rb +26 -2
  47. data/lib/parse/model/file.rb +523 -18
  48. data/lib/parse/query.rb +31 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +98 -1
  51. data/parse-stack-next.gemspec +2 -2
  52. metadata +17 -7
@@ -225,6 +225,24 @@ module Parse
225
225
  # @return [Array<Parse::Session>] A list of active Parse::Session objects.
226
226
  has_many :active_sessions, as: :session
227
227
 
228
+ # @!attribute installations
229
+ # A `has_many` query-form association resolving to all
230
+ # {Parse::Installation} records whose `user` pointer is this user.
231
+ # Useful for targeted push — e.g. sending a notification to every
232
+ # device the user is signed into. This is a query (no column is
233
+ # stored on `_User`); each access issues a `find` against
234
+ # `_Installation` for `where(user: self)`.
235
+ #
236
+ # **Requires a master-key client.** Parse Server hardcodes
237
+ # `_Installation` `find` to master-key-only at the REST layer, so
238
+ # this association will return an empty array (or fail-closed
239
+ # depending on agent scope) under a session-token-only / sessionless
240
+ # client. The `user` pointer is also not a reliable owner identity
241
+ # — devices outlive sessions and can change users — see
242
+ # {Parse::Installation} for the full caveat list.
243
+ # @return [Array<Parse::Installation>]
244
+ has_many :installations, as: :installation
245
+
228
246
  # CHANGE -- ACLs can be managed
229
247
  # before_save do
230
248
  # # You cannot specify user ACLs.
@@ -278,6 +296,192 @@ module Parse
278
296
  def authdata_trusted?
279
297
  Thread.current[AUTHDATA_TRUST_KEY] == true
280
298
  end
299
+
300
+ # =========================================================================
301
+ # Field-visibility DSL for _User
302
+ # =========================================================================
303
+ #
304
+ # `_User` has two field-visibility flavors that vanilla
305
+ # {Parse::Object.protect_fields} can't express on its own because
306
+ # Parse Server's `protectedFieldsOwnerExempt` option special-cases
307
+ # the owning user (the user sees their own row in full unless the
308
+ # option is disabled). These helpers wrap that pattern.
309
+ #
310
+ # ## Prerequisites
311
+ #
312
+ # 1. Set `protectedFieldsOwnerExempt: false` in your Parse Server
313
+ # startup options. With the historical default `true`, the owning
314
+ # user is silently exempted from every `protectedFields` rule on
315
+ # `_User`, so {.master_only_fields} would still be visible to the
316
+ # user themselves. Parse Server's default for this option is
317
+ # changing to `false` in a future version; until your server
318
+ # adopts that default you must set it explicitly.
319
+ # 2. For {.self_visible_fields}: add a self-pointer field on `_User`
320
+ # that points to the same user, and maintain it from a
321
+ # `beforeSave('_User')` Cloud Code trigger:
322
+ #
323
+ # ```js
324
+ # Parse.Cloud.beforeSave(Parse.User, (req) => {
325
+ # const u = req.object;
326
+ # if (!u.get('self')) u.set('self', u); // self-pointer
327
+ # });
328
+ # ```
329
+ #
330
+ # The SDK cannot install either of those — they're server-side
331
+ # configuration — but the helpers below will warn if they detect
332
+ # they're being mis-applied.
333
+
334
+ # Hide one or more fields from query/get responses for **all**
335
+ # non-master callers, including the owning user themselves.
336
+ # Useful for admin-only metadata living on `_User`
337
+ # (e.g. internal scoring, moderation notes).
338
+ #
339
+ # Requires Parse Server option `protectedFieldsOwnerExempt: false`.
340
+ # With the default `true`, the owning user still sees these fields
341
+ # on their own row.
342
+ #
343
+ # @param fields [Array<Symbol,String>] field names. Use snake_case
344
+ # Ruby property names; they're auto-converted to camelCase.
345
+ # @return [Array<Symbol>] full master-only field list after this call.
346
+ # @example
347
+ # class Parse::User
348
+ # property :my_opinion_of_them, :string
349
+ # master_only_fields :my_opinion_of_them
350
+ # end
351
+ def master_only_fields(*fields)
352
+ @master_only_fields ||= []
353
+ @master_only_fields = (@master_only_fields + fields.flatten.map(&:to_sym)).uniq
354
+ _warn_about_owner_exempt_prereq!
355
+ _rebuild_user_protected_fields!
356
+ @master_only_fields.dup
357
+ end
358
+
359
+ # Hide one or more fields from public/role/other-user callers, but
360
+ # allow the **owning user** to see them. Useful for private profile
361
+ # data that belongs to the user (e.g. preferences, private notes).
362
+ #
363
+ # Requires:
364
+ # * Parse Server option `protectedFieldsOwnerExempt: false`.
365
+ # * A self-pointer field on `_User` (named via `via:`, default
366
+ # `:self`) that is set to the row's own pointer by a
367
+ # `beforeSave('_User')` Cloud Code trigger.
368
+ #
369
+ # @param fields [Array<Symbol,String>] field names. snake_case OK.
370
+ # @param via [Symbol,String] name of the self-pointer field on
371
+ # `_User` (default `:self`).
372
+ # @return [Array<Symbol>]
373
+ # @example
374
+ # class Parse::User
375
+ # property :favorite_color, :string
376
+ # self_visible_fields :favorite_color, via: :self
377
+ # end
378
+ def self_visible_fields(*fields, via: :self)
379
+ @self_visible_fields ||= []
380
+ @self_visible_fields = (@self_visible_fields + fields.flatten.map(&:to_sym)).uniq
381
+ @self_pointer_field = via.to_sym
382
+ _warn_about_owner_exempt_prereq!
383
+ _warn_about_self_pointer_prereq!(via)
384
+ _rebuild_user_protected_fields!
385
+ @self_visible_fields.dup
386
+ end
387
+
388
+ # Override {Parse::Object.protect_fields} on `_User` so that ad-hoc
389
+ # uses (i.e. not through {.master_only_fields} /
390
+ # {.self_visible_fields}) emit a one-time advisory pointing at the
391
+ # higher-level helpers and the `protectedFieldsOwnerExempt` flag.
392
+ # The behavior is otherwise unchanged.
393
+ def protect_fields(pattern, fields)
394
+ _warn_about_user_protect_fields! unless @_user_field_dsl_active
395
+ super
396
+ end
397
+
398
+ # @!visibility private
399
+ def _rebuild_user_protected_fields!
400
+ @master_only_fields ||= []
401
+ @self_visible_fields ||= []
402
+ pointer = @self_pointer_field || :self
403
+ all_hidden = (@master_only_fields + @self_visible_fields).uniq
404
+
405
+ @_user_field_dsl_active = true
406
+ begin
407
+ protect_fields("*", all_hidden) unless all_hidden.empty?
408
+ unless @self_visible_fields.empty?
409
+ protect_fields("userField:#{pointer}", @master_only_fields)
410
+ end
411
+ ensure
412
+ @_user_field_dsl_active = false
413
+ end
414
+ end
415
+
416
+ # @!visibility private
417
+ def _warn_about_user_protect_fields!
418
+ return if @_user_protect_fields_warned
419
+ @_user_protect_fields_warned = true
420
+ _emit_user_field_advisory(
421
+ "[Parse::User] protect_fields was called directly on _User. " \
422
+ "For master-only and owner-visible field patterns prefer " \
423
+ "`Parse::User.master_only_fields` and `Parse::User.self_visible_fields`. " \
424
+ "Either way, ensure Parse Server is started with " \
425
+ "`protectedFieldsOwnerExempt: false` (the historical default `true` — " \
426
+ "changing to `false` in a future Parse Server version — exempts the " \
427
+ "owning user from every protectedFields rule on _User, which silently " \
428
+ "negates these protections for the user's own row).",
429
+ )
430
+ end
431
+
432
+ # @!visibility private
433
+ # Fires once when `master_only_fields` / `self_visible_fields` is first
434
+ # used. Without `protectedFieldsOwnerExempt: false` in Parse Server's
435
+ # startup options, neither helper does what its name promises -- the
436
+ # default `true` silently exempts the owning user from every
437
+ # protectedFields rule on _User. The SDK can't introspect Parse
438
+ # Server's startup options, so we surface this as a one-time advisory
439
+ # at declaration time so it's loud enough to catch before deploy.
440
+ def _warn_about_owner_exempt_prereq!
441
+ return if @_owner_exempt_warned
442
+ @_owner_exempt_warned = true
443
+ _emit_user_field_advisory(
444
+ "[Parse::User] master_only_fields / self_visible_fields require " \
445
+ "Parse Server option `protectedFieldsOwnerExempt: false`. With the " \
446
+ "historical default `true`, the owning user is silently exempted " \
447
+ "from every protectedFields rule on _User, so a field declared " \
448
+ "master-only would still be visible to the user themselves on their " \
449
+ "own row. Parse Server's default for this option is changing to " \
450
+ "`false` in a future version (which makes these helpers work " \
451
+ "without extra config), but until your server adopts that default " \
452
+ "you must set `protectedFieldsOwnerExempt: false` in your " \
453
+ "ParseServer options BEFORE deploying. See docs/acl_clp_guide.md §4.2.",
454
+ )
455
+ end
456
+
457
+ # @!visibility private
458
+ # Fires once when `self_visible_fields` is first used. The Parse
459
+ # Server side requires (a) a self-pointer field on _User populated
460
+ # by a beforeSave('_User') trigger, AND (b) a one-shot backfill on
461
+ # any pre-existing user rows so the pointer is set before the
462
+ # `userField:<via>` group matches them.
463
+ def _warn_about_self_pointer_prereq!(via)
464
+ return if @_self_pointer_warned
465
+ @_self_pointer_warned = true
466
+ _emit_user_field_advisory(
467
+ "[Parse::User] self_visible_fields(via: :#{via}) requires a " \
468
+ "self-pointer field named `#{via}` on _User pointing at the same " \
469
+ "row, populated by a beforeSave('_User') Cloud Code trigger. " \
470
+ "Existing user rows ALSO need a one-shot backfill (the trigger " \
471
+ "only fires on save) -- without it, those rows never match the " \
472
+ "`userField:#{via}` group and the field stays hidden from the " \
473
+ "user themselves. See docs/acl_clp_guide.md §4.2.",
474
+ )
475
+ end
476
+
477
+ # @!visibility private
478
+ def _emit_user_field_advisory(msg)
479
+ if Parse.respond_to?(:logger) && Parse.logger
480
+ Parse.logger.warn(msg)
481
+ else
482
+ Kernel.warn(msg)
483
+ end
484
+ end
281
485
  end
282
486
 
283
487
  # @!visibility private
@@ -6,6 +6,7 @@ require "openssl"
6
6
  require "json"
7
7
  require "securerandom"
8
8
  require "monitor"
9
+ require_relative "../../lock_backend"
9
10
 
10
11
  module Parse
11
12
  # Mutual-exclusion primitive for `first_or_create!` / `create_or_update!` to
@@ -70,25 +71,29 @@ module Parse
70
71
  master_key: master_key,
71
72
  )
72
73
 
73
- store = lock_store
74
- if degraded_store?(store)
75
- handle_degraded(on_degraded, key)
76
- return process_mutex(key).synchronize(&block)
74
+ store = LockBackend.lock_store
75
+ if LockBackend.degraded_store?(store)
76
+ LockBackend.handle_degraded(
77
+ on_degraded, key,
78
+ source: "Parse::CreateLock",
79
+ unavailable_error: Parse::CreateLockUnavailableError,
80
+ )
81
+ return LockBackend.process_mutex(key).synchronize(&block)
77
82
  end
78
83
 
79
84
  owner = SecureRandom.uuid
80
85
  acquired_at = nil
81
- start = monotonic_now
86
+ start = LockBackend.monotonic_now
82
87
 
83
88
  loop do
84
- if try_acquire(store, key, owner, ttl)
85
- acquired_at = monotonic_now
89
+ if LockBackend.try_acquire(store, key, owner, ttl)
90
+ acquired_at = LockBackend.monotonic_now
86
91
  wait_ms = ((acquired_at - start) * 1000).round
87
92
  instrument("acquired", key, wait_ms: wait_ms)
88
93
  break
89
94
  end
90
95
 
91
- elapsed = monotonic_now - start
96
+ elapsed = LockBackend.monotonic_now - start
92
97
  if elapsed >= wait
93
98
  waited_ms = (elapsed * 1000).round
94
99
  instrument("timeout", key, waited_ms: waited_ms)
@@ -96,15 +101,15 @@ module Parse
96
101
  "Could not acquire create-lock for #{parse_class} within #{wait}s"
97
102
  end
98
103
  instrument("contended", key, elapsed_ms: (elapsed * 1000).round) if elapsed > 0
99
- sleep(poll_interval)
104
+ sleep(LockBackend.poll_interval)
100
105
  end
101
106
 
102
107
  begin
103
108
  yield
104
109
  ensure
105
110
  if acquired_at
106
- release(store, key, owner)
107
- held_ms = ((monotonic_now - acquired_at) * 1000).round
111
+ LockBackend.release(store, key, owner)
112
+ held_ms = ((LockBackend.monotonic_now - acquired_at) * 1000).round
108
113
  instrument("released", key, held_ms: held_ms)
109
114
  end
110
115
  end
@@ -123,7 +128,7 @@ module Parse
123
128
  "synchronize key payload exceeds #{MAX_PAYLOAD_BYTES} bytes (got #{payload.bytesize})"
124
129
  end
125
130
 
126
- secret = lock_secret_for(store: lock_store)
131
+ secret = lock_secret_for(store: LockBackend.lock_store)
127
132
  digest = if secret
128
133
  OpenSSL::HMAC.hexdigest("SHA256", secret, payload)
129
134
  else
@@ -134,23 +139,14 @@ module Parse
134
139
 
135
140
  # @!visibility private
136
141
  def reset!
137
- @auto_secret = nil
138
- @plain_sha_warned = nil
139
- @degraded_warned_at = nil
140
- @process_mutex_registry = nil
142
+ # All resettable state lives on Parse::LockBackend now —
143
+ # @degraded_warned_at, @process_mutex_registry, @auto_secret,
144
+ # @plain_sha_warned. v5.1.0 extraction.
145
+ LockBackend.reset!
141
146
  end
142
147
 
143
148
  private
144
149
 
145
- def lock_store
146
- if Parse.respond_to?(:synchronize_create_store) && Parse.synchronize_create_store
147
- return Parse.synchronize_create_store
148
- end
149
- Parse.cache
150
- rescue Parse::Error::ConnectionError
151
- nil
152
- end
153
-
154
150
  def parse_application_id
155
151
  Parse.client.application_id
156
152
  rescue Parse::Error::ConnectionError
@@ -253,133 +249,19 @@ module Parse
253
249
  end
254
250
  end
255
251
 
256
- def degraded_store?(store)
257
- return true if store.nil?
258
- # The Parse::Cache::Redis wrapper (and its Pool) are known
259
- # cross-process stores even though they don't expose a Moneta
260
- # `.adapter` chain to walk. Anything that can't forward `#create`
261
- # cannot serve as a lock store, so fall back to the process-local
262
- # path rather than spinning until timeout on NoMethodError.
263
- return false if defined?(Parse::Cache::Redis) && store.is_a?(Parse::Cache::Redis)
264
- return true unless store.respond_to?(:create)
265
- bottom = walk_to_adapter(store)
266
- return true if bottom.nil?
267
- klass_name = bottom.class.name.to_s
268
- klass_name.include?("Memory") || klass_name.include?("Null")
269
- end
270
-
271
- def walk_to_adapter(store)
272
- current = store
273
- # Walk transformer chain to find bottom Moneta adapter
274
- while current.respond_to?(:adapter) && current.adapter && current.adapter != current
275
- current = current.adapter
276
- end
277
- current
278
- end
279
-
280
- def handle_degraded(mode, key)
281
- case mode
282
- when :raise
283
- raise Parse::CreateLockUnavailableError,
284
- "synchronize requires a cross-process cache store (Redis); current store is process-local"
285
- when :proceed
286
- # silent
287
- when :warn_throttled
288
- now = monotonic_now
289
- if @degraded_warned_at.nil? || (now - @degraded_warned_at) >= DEGRADED_WARNING_THROTTLE_SECONDS
290
- @degraded_warned_at = now
291
- warn "[Parse::CreateLock] Lock store is process-local (Moneta Memory/Null). " \
292
- "Cross-process locking is NOT in effect. Configure a Redis-backed cache to enable distributed locking."
293
- end
294
- else # :warn (default)
295
- warn "[Parse::CreateLock] Lock store is process-local; cross-process locking disabled. " \
296
- "key_digest=#{key[KEY_PREFIX.size, 12]}…"
297
- end
298
- end
299
-
300
- def try_acquire(store, key, owner, ttl)
301
- # Trigger lazy TTL sweep on Moneta::Memory before #create (no-op on Redis).
302
- # Without this, Memory adapter returns false on #create even after TTL expiry
303
- # until a #key? or #[] access flushes the stale entry.
304
- store.key?(key)
305
- store.create(key, owner, expires: ttl)
306
- rescue StandardError => e
307
- warn "[Parse::CreateLock] acquire error (#{e.class}): #{e.message}"
308
- false
309
- end
310
-
311
- def release(store, key, owner)
312
- # Best-effort compare-and-delete. Moneta does not expose atomic CAD;
313
- # the worst-case race here is bounded by the short TTL (default #{DEFAULT_TTL}s).
314
- current = store[key]
315
- store.delete(key) if current == owner
316
- rescue StandardError => e
317
- warn "[Parse::CreateLock] release error (#{e.class}): #{e.message}"
318
- end
319
-
320
- def poll_interval
321
- DEFAULT_POLL_BASE + (rand * 2 - 1) * DEFAULT_POLL_JITTER
322
- end
323
-
324
- def monotonic_now
325
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
326
- end
252
+ # All the lock infrastructure — store discovery, degraded
253
+ # detection, atomic-SETNX, release semantics, poll-interval
254
+ # jitter, process-mutex registry, AND HMAC secret resolution
255
+ # lives on {Parse::LockBackend} now (v5.1.0 extraction). The
256
+ # only CreateLock-specific helper still here is `clamp` for
257
+ # the public API's input range.
327
258
 
328
259
  def clamp(value, lo, hi)
329
260
  [lo, value, hi].sort[1]
330
261
  end
331
262
 
332
- def process_mutex(key)
333
- @process_mutex_registry_lock ||= Mutex.new
334
- @process_mutex_registry_lock.synchronize do
335
- @process_mutex_registry ||= {}
336
- @process_mutex_registry[key] ||= Mutex.new
337
- end
338
- end
339
-
340
263
  def lock_secret_for(store:)
341
- configured = configured_secret
342
- return configured if configured && !configured.empty?
343
-
344
- # No operator-configured secret. Behavior depends on store type:
345
- # - Memory/Null adapter: locking is already process-local, so a
346
- # per-process auto-derived HMAC secret is fine and preserves
347
- # privacy in tests / single-process deployments.
348
- # - Redis (or any cross-process store): a per-process secret would
349
- # break cross-process key equality, defeating the lock. Fall back
350
- # to plain SHA256 with a one-time warning so operators know to
351
- # harden key material.
352
- if degraded_store?(store)
353
- auto_secret
354
- else
355
- warn_plain_sha_once
356
- nil
357
- end
358
- end
359
-
360
- def configured_secret
361
- if Parse.respond_to?(:synchronize_create_secret) && Parse.synchronize_create_secret
362
- return Parse.synchronize_create_secret.to_s
363
- end
364
- ENV["PARSE_STACK_LOCK_SECRET"]
365
- end
366
-
367
- def auto_secret
368
- @auto_secret ||= SecureRandom.hex(32)
369
- end
370
-
371
- def warn_plain_sha_once
372
- return if @plain_sha_warned
373
- @plain_sha_warned = true
374
- warn "[Parse::CreateLock:SECURITY] No PARSE_STACK_LOCK_SECRET configured and Redis-backed store detected. " \
375
- "Falling back to plain SHA256 for lock-key derivation so cross-process locking actually works. " \
376
- "Risks of running without an HMAC secret: (1) lock keys are deterministic and may expose query_attrs " \
377
- "content via Redis MONITOR/snapshots; (2) when the response cache and the lock store share a Redis DB, " \
378
- "any caller with write access to Parse.cache can plant a `parse-stack:foc:v1:<sha>` key under a guessable " \
379
- "digest of (app_id, class, principal, query_attrs) and suppress first_or_create!/create_or_update! for " \
380
- "that tuple until TTL expiry — a targeted DoS / create-pinning primitive. " \
381
- "Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying, or " \
382
- "point Parse.synchronize_create_store at a separate Redis DB from the response cache."
264
+ LockBackend.lock_secret_for(store: store, source: "Parse::CreateLock")
383
265
  end
384
266
 
385
267
  def instrument(event, key, payload = {})