parse-stack-next 5.0.0 → 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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
- data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
- data/.github/dependabot.yml +13 -0
- data/.github/workflows/codeql.yml +1 -1
- data/.github/workflows/docs.yml +3 -3
- data/.github/workflows/release.yml +43 -0
- data/.github/workflows/ruby.yml +1 -1
- data/.gitignore +1 -0
- data/.vscode/settings.json +3 -0
- data/.yardopts +19 -0
- data/CHANGELOG.md +802 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +8 -5
- data/README.md +16 -1
- data/Rakefile +5 -1
- data/docs/acl_clp_guide.md +553 -0
- data/docs/atlas_vector_search_guide.md +123 -22
- data/docs/client_sdk_guide.md +201 -5
- data/docs/usage_guide.md +21 -0
- data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
- data/lib/parse/agent/tools.rb +153 -1
- data/lib/parse/cache/pool.rb +15 -0
- data/lib/parse/cache/redis.rb +114 -2
- data/lib/parse/client/caching.rb +18 -1
- data/lib/parse/client.rb +79 -12
- data/lib/parse/embeddings/cohere.rb +143 -6
- data/lib/parse/embeddings/provider.rb +20 -2
- data/lib/parse/embeddings/voyage.rb +102 -0
- data/lib/parse/embeddings.rb +332 -1
- data/lib/parse/live_query/client.rb +167 -4
- data/lib/parse/live_query/configuration.rb +12 -0
- data/lib/parse/live_query/subscription.rb +55 -2
- data/lib/parse/live_query.rb +123 -1
- data/lib/parse/lock.rb +342 -0
- data/lib/parse/lock_backend.rb +308 -0
- data/lib/parse/model/classes/audience.rb +5 -0
- data/lib/parse/model/classes/installation.rb +122 -0
- data/lib/parse/model/classes/job_schedule.rb +3 -1
- data/lib/parse/model/classes/job_status.rb +4 -1
- data/lib/parse/model/classes/push_status.rb +4 -1
- data/lib/parse/model/classes/session.rb +7 -0
- data/lib/parse/model/classes/user.rb +204 -0
- data/lib/parse/model/core/create_lock.rb +28 -134
- data/lib/parse/model/core/embed_managed.rb +162 -13
- data/lib/parse/model/core/parse_reference.rb +17 -1
- data/lib/parse/model/core/querying.rb +26 -2
- data/lib/parse/model/file.rb +523 -18
- data/lib/parse/query.rb +31 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +98 -1
- data/parse-stack-next.gemspec +2 -2
- metadata +19 -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(
|
|
76
|
-
|
|
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
|
-
|
|
138
|
-
@
|
|
139
|
-
@
|
|
140
|
-
|
|
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,121 +249,19 @@ module Parse
|
|
|
253
249
|
end
|
|
254
250
|
end
|
|
255
251
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def walk_to_adapter(store)
|
|
265
|
-
current = store
|
|
266
|
-
# Walk transformer chain to find bottom Moneta adapter
|
|
267
|
-
while current.respond_to?(:adapter) && current.adapter && current.adapter != current
|
|
268
|
-
current = current.adapter
|
|
269
|
-
end
|
|
270
|
-
current
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
def handle_degraded(mode, key)
|
|
274
|
-
case mode
|
|
275
|
-
when :raise
|
|
276
|
-
raise Parse::CreateLockUnavailableError,
|
|
277
|
-
"synchronize requires a cross-process cache store (Redis); current store is process-local"
|
|
278
|
-
when :proceed
|
|
279
|
-
# silent
|
|
280
|
-
when :warn_throttled
|
|
281
|
-
now = monotonic_now
|
|
282
|
-
if @degraded_warned_at.nil? || (now - @degraded_warned_at) >= DEGRADED_WARNING_THROTTLE_SECONDS
|
|
283
|
-
@degraded_warned_at = now
|
|
284
|
-
warn "[Parse::CreateLock] Lock store is process-local (Moneta Memory/Null). " \
|
|
285
|
-
"Cross-process locking is NOT in effect. Configure a Redis-backed cache to enable distributed locking."
|
|
286
|
-
end
|
|
287
|
-
else # :warn (default)
|
|
288
|
-
warn "[Parse::CreateLock] Lock store is process-local; cross-process locking disabled. " \
|
|
289
|
-
"key_digest=#{key[KEY_PREFIX.size, 12]}…"
|
|
290
|
-
end
|
|
291
|
-
end
|
|
292
|
-
|
|
293
|
-
def try_acquire(store, key, owner, ttl)
|
|
294
|
-
# Trigger lazy TTL sweep on Moneta::Memory before #create (no-op on Redis).
|
|
295
|
-
# Without this, Memory adapter returns false on #create even after TTL expiry
|
|
296
|
-
# until a #key? or #[] access flushes the stale entry.
|
|
297
|
-
store.key?(key)
|
|
298
|
-
store.create(key, owner, expires: ttl)
|
|
299
|
-
rescue StandardError => e
|
|
300
|
-
warn "[Parse::CreateLock] acquire error (#{e.class}): #{e.message}"
|
|
301
|
-
false
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
def release(store, key, owner)
|
|
305
|
-
# Best-effort compare-and-delete. Moneta does not expose atomic CAD;
|
|
306
|
-
# the worst-case race here is bounded by the short TTL (default #{DEFAULT_TTL}s).
|
|
307
|
-
current = store[key]
|
|
308
|
-
store.delete(key) if current == owner
|
|
309
|
-
rescue StandardError => e
|
|
310
|
-
warn "[Parse::CreateLock] release error (#{e.class}): #{e.message}"
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def poll_interval
|
|
314
|
-
DEFAULT_POLL_BASE + (rand * 2 - 1) * DEFAULT_POLL_JITTER
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
def monotonic_now
|
|
318
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
319
|
-
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.
|
|
320
258
|
|
|
321
259
|
def clamp(value, lo, hi)
|
|
322
260
|
[lo, value, hi].sort[1]
|
|
323
261
|
end
|
|
324
262
|
|
|
325
|
-
def process_mutex(key)
|
|
326
|
-
@process_mutex_registry_lock ||= Mutex.new
|
|
327
|
-
@process_mutex_registry_lock.synchronize do
|
|
328
|
-
@process_mutex_registry ||= {}
|
|
329
|
-
@process_mutex_registry[key] ||= Mutex.new
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
263
|
def lock_secret_for(store:)
|
|
334
|
-
|
|
335
|
-
return configured if configured && !configured.empty?
|
|
336
|
-
|
|
337
|
-
# No operator-configured secret. Behavior depends on store type:
|
|
338
|
-
# - Memory/Null adapter: locking is already process-local, so a
|
|
339
|
-
# per-process auto-derived HMAC secret is fine and preserves
|
|
340
|
-
# privacy in tests / single-process deployments.
|
|
341
|
-
# - Redis (or any cross-process store): a per-process secret would
|
|
342
|
-
# break cross-process key equality, defeating the lock. Fall back
|
|
343
|
-
# to plain SHA256 with a one-time warning so operators know to
|
|
344
|
-
# harden key material.
|
|
345
|
-
if degraded_store?(store)
|
|
346
|
-
auto_secret
|
|
347
|
-
else
|
|
348
|
-
warn_plain_sha_once
|
|
349
|
-
nil
|
|
350
|
-
end
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def configured_secret
|
|
354
|
-
if Parse.respond_to?(:synchronize_create_secret) && Parse.synchronize_create_secret
|
|
355
|
-
return Parse.synchronize_create_secret.to_s
|
|
356
|
-
end
|
|
357
|
-
ENV["PARSE_STACK_LOCK_SECRET"]
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
def auto_secret
|
|
361
|
-
@auto_secret ||= SecureRandom.hex(32)
|
|
362
|
-
end
|
|
363
|
-
|
|
364
|
-
def warn_plain_sha_once
|
|
365
|
-
return if @plain_sha_warned
|
|
366
|
-
@plain_sha_warned = true
|
|
367
|
-
warn "[Parse::CreateLock:SECURITY] No PARSE_STACK_LOCK_SECRET configured and Redis-backed store detected. " \
|
|
368
|
-
"Falling back to plain SHA256 for lock-key derivation so cross-process locking actually works. " \
|
|
369
|
-
"Lock keys are deterministic and may expose query_attrs content via Redis MONITOR/snapshots. " \
|
|
370
|
-
"Set PARSE_STACK_LOCK_SECRET (or Parse.synchronize_create_secret = '…') to enable HMAC keying."
|
|
264
|
+
LockBackend.lock_secret_for(store: store, source: "Parse::CreateLock")
|
|
371
265
|
end
|
|
372
266
|
|
|
373
267
|
def instrument(event, key, payload = {})
|
|
@@ -88,18 +88,34 @@ module Parse
|
|
|
88
88
|
# field write; the guard is otherwise closed.
|
|
89
89
|
WRITER_KEY = :parse_embed_managed_writer
|
|
90
90
|
|
|
91
|
-
# Frozen value-object capturing one `embed`
|
|
92
|
-
# the owning class under
|
|
91
|
+
# Frozen value-object capturing one `embed` or `embed_image`
|
|
92
|
+
# declaration. Stored on the owning class under
|
|
93
|
+
# `embed_directives[into]` and passed to
|
|
93
94
|
# {EmbedManaged.recompute_embedding!} from the per-class
|
|
94
95
|
# before_save callback.
|
|
96
|
+
#
|
|
97
|
+
# `modality` is `nil` (treated as `:text`) for {.embed}-declared
|
|
98
|
+
# directives and `:image` for {.embed_image}. The image path
|
|
99
|
+
# routes through `Parse::Embeddings.validate_image_url!` and
|
|
100
|
+
# `Provider#embed_image` rather than `Provider#embed_text`;
|
|
101
|
+
# digest tracking is over the file URL String rather than the
|
|
102
|
+
# concatenated source text.
|
|
103
|
+
#
|
|
104
|
+
# `allow_insecure` is forwarded to {.validate_image_url!} for
|
|
105
|
+
# image directives only; ignored for text.
|
|
95
106
|
EmbedDirective = Struct.new(
|
|
96
107
|
:sources, :into, :digest_field, :input_type, :provider_name,
|
|
108
|
+
:modality, :allow_insecure,
|
|
97
109
|
keyword_init: true,
|
|
98
110
|
) do
|
|
99
111
|
def freeze
|
|
100
112
|
sources.freeze
|
|
101
113
|
super
|
|
102
114
|
end
|
|
115
|
+
|
|
116
|
+
def image?
|
|
117
|
+
modality == :image
|
|
118
|
+
end
|
|
103
119
|
end
|
|
104
120
|
|
|
105
121
|
# @!visibility private
|
|
@@ -186,6 +202,99 @@ module Parse
|
|
|
186
202
|
into
|
|
187
203
|
end
|
|
188
204
|
|
|
205
|
+
# Declare a managed image embedding. Mirrors {.embed} but the
|
|
206
|
+
# source field is a `:file` property (Parse::File) and the
|
|
207
|
+
# provider call routes through {Parse::Embeddings::Provider#embed_image}
|
|
208
|
+
# rather than `#embed_text`. v5.1 ships URL-only: the SDK
|
|
209
|
+
# extracts the file's URL, validates it through
|
|
210
|
+
# {Parse::Embeddings.validate_image_url!} (sentinel-gated egress
|
|
211
|
+
# opt-in, CIDR / port / host allowlist), and forwards the
|
|
212
|
+
# canonicalized URL to the provider. The SDK does NOT download
|
|
213
|
+
# image bytes — bytes-fetch is the v5.3 path.
|
|
214
|
+
#
|
|
215
|
+
# **Digest is the URL string, not the file contents.** Replacing
|
|
216
|
+
# the Parse::File with one pointing to a different URL re-embeds;
|
|
217
|
+
# re-saving the same URL is a no-op (zero provider calls).
|
|
218
|
+
# Cloud-stored Parse files have stable URLs unless overwritten,
|
|
219
|
+
# so this is the right cache key for most uploads. If you mutate
|
|
220
|
+
# the underlying bytes at the SAME URL (e.g. PUT-replace on S3
|
|
221
|
+
# without renaming), the embedding will NOT refresh; rename the
|
|
222
|
+
# file or set `:#{into}_digest` to nil and resave to force re-embed.
|
|
223
|
+
#
|
|
224
|
+
# @param source_field [Symbol] one `:file` property whose URL
|
|
225
|
+
# feeds the provider. (v5.1 accepts a single source per
|
|
226
|
+
# directive; multi-image-per-record support is deferred.)
|
|
227
|
+
# @param into [Symbol] the `:vector` property to populate.
|
|
228
|
+
# Must already be declared with `provider:` metadata.
|
|
229
|
+
# @param input_type [Symbol] forwarded to {Provider#embed_image}.
|
|
230
|
+
# Defaults to `:search_document`.
|
|
231
|
+
# @param digest_field [Symbol, nil] override for the URL-digest
|
|
232
|
+
# sibling. Defaults to `:"#{into}_digest"`. Auto-declared as
|
|
233
|
+
# `:string` if not already declared.
|
|
234
|
+
# @param allow_insecure [Boolean] forwarded to
|
|
235
|
+
# {Parse::Embeddings.validate_image_url!}; permit `http://`
|
|
236
|
+
# for local-dev CDN proxies. Default false.
|
|
237
|
+
# @return [Symbol] the target vector field name.
|
|
238
|
+
# @raise [InvalidEmbedDeclaration] on declaration-time misuse.
|
|
239
|
+
def embed_image(source_field, into:, input_type: :search_document,
|
|
240
|
+
digest_field: nil, allow_insecure: false)
|
|
241
|
+
into = into.to_sym
|
|
242
|
+
unless vector_properties.key?(into)
|
|
243
|
+
raise InvalidEmbedDeclaration,
|
|
244
|
+
"#{self}.embed_image: `into: :#{into}` is not a declared :vector property " \
|
|
245
|
+
"(declared :vector fields: #{vector_properties.keys.inspect})."
|
|
246
|
+
end
|
|
247
|
+
provider_name = vector_properties.dig(into, :provider)
|
|
248
|
+
if provider_name.nil?
|
|
249
|
+
raise InvalidEmbedDeclaration,
|
|
250
|
+
"#{self}.embed_image: `into: :#{into}` has no `provider:` declared on its " \
|
|
251
|
+
":vector property. Add `provider: :voyage` (or another registered name) " \
|
|
252
|
+
"to the property declaration."
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
source = source_field.to_sym
|
|
256
|
+
unless fields.key?(source)
|
|
257
|
+
raise InvalidEmbedDeclaration,
|
|
258
|
+
"#{self}.embed_image: source field #{source.inspect} is not declared on this class."
|
|
259
|
+
end
|
|
260
|
+
unless fields[source] == :file
|
|
261
|
+
raise InvalidEmbedDeclaration,
|
|
262
|
+
"#{self}.embed_image: source field #{source.inspect} must be a :file property " \
|
|
263
|
+
"(got #{fields[source].inspect}). v5.1 image embedding accepts Parse::File " \
|
|
264
|
+
"sources only — text sources go through `embed`."
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
digest_field = (digest_field || :"#{into}_digest").to_sym
|
|
268
|
+
unless fields.key?(digest_field)
|
|
269
|
+
property digest_field, :string
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
directive = EmbedDirective.new(
|
|
273
|
+
sources: [source],
|
|
274
|
+
into: into,
|
|
275
|
+
digest_field: digest_field,
|
|
276
|
+
input_type: input_type,
|
|
277
|
+
provider_name: provider_name,
|
|
278
|
+
modality: :image,
|
|
279
|
+
allow_insecure: allow_insecure,
|
|
280
|
+
).freeze
|
|
281
|
+
embed_directives[into] = directive
|
|
282
|
+
|
|
283
|
+
callback_method = :"_auto_embed_#{into}!"
|
|
284
|
+
define_method(callback_method) do
|
|
285
|
+
Parse::Core::EmbedManaged.recompute_embedding!(self, directive)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
already_registered = _save_callbacks.any? do |cb|
|
|
289
|
+
cb.kind == :before && (cb.filter.to_sym rescue cb.filter) == callback_method
|
|
290
|
+
end
|
|
291
|
+
before_save callback_method unless already_registered
|
|
292
|
+
|
|
293
|
+
install_embed_writer_guard!(into, [source])
|
|
294
|
+
|
|
295
|
+
into
|
|
296
|
+
end
|
|
297
|
+
|
|
189
298
|
# @!visibility private
|
|
190
299
|
# Prepend a module that intercepts the public `<into>=` setter
|
|
191
300
|
# and raises {ProtectedFieldError} unless the current thread has
|
|
@@ -225,19 +334,19 @@ module Parse
|
|
|
225
334
|
end
|
|
226
335
|
|
|
227
336
|
# @!visibility private
|
|
228
|
-
# before_save body.
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
337
|
+
# before_save body. Dispatches on `directive.modality`: text
|
|
338
|
+
# directives concatenate source-field values and call
|
|
339
|
+
# `Provider#embed_text`; image directives extract the source
|
|
340
|
+
# Parse::File's URL, validate it via
|
|
341
|
+
# `Parse::Embeddings.validate_image_url!`, and call
|
|
342
|
+
# `Provider#embed_image`. Digest tracking elides the provider
|
|
343
|
+
# call when the source has not changed since last save.
|
|
235
344
|
def self.recompute_embedding!(record, directive)
|
|
236
|
-
|
|
345
|
+
input = build_source_input(record, directive)
|
|
237
346
|
stored_digest = record.public_send(directive.digest_field)
|
|
238
347
|
target_present = !record.public_send(directive.into).nil?
|
|
239
348
|
|
|
240
|
-
if
|
|
349
|
+
if input.nil? || input.empty?
|
|
241
350
|
if target_present || !stored_digest.nil?
|
|
242
351
|
with_writer(directive.into) do
|
|
243
352
|
record.public_send(:"#{directive.into}=", nil)
|
|
@@ -247,11 +356,11 @@ module Parse
|
|
|
247
356
|
return
|
|
248
357
|
end
|
|
249
358
|
|
|
250
|
-
digest = digest_for(
|
|
359
|
+
digest = digest_for(input)
|
|
251
360
|
return if stored_digest == digest && target_present
|
|
252
361
|
|
|
253
362
|
provider = Parse::Embeddings.provider(directive.provider_name)
|
|
254
|
-
vectors = provider
|
|
363
|
+
vectors = call_provider(provider, directive, input)
|
|
255
364
|
unless vectors.is_a?(Array) && vectors.length == 1 && vectors.first.is_a?(Array)
|
|
256
365
|
raise Parse::Embeddings::InvalidResponseError,
|
|
257
366
|
"Parse::Core::EmbedManaged (#{record.class}##{directive.into}): provider " \
|
|
@@ -273,6 +382,46 @@ module Parse
|
|
|
273
382
|
record.public_send(:"#{directive.digest_field}=", digest)
|
|
274
383
|
end
|
|
275
384
|
|
|
385
|
+
# @!visibility private
|
|
386
|
+
# Build the provider input for `directive`: concatenated text for
|
|
387
|
+
# text directives; the raw image URL for image directives.
|
|
388
|
+
# Returns `nil` (treated as "clear the embedding") when the source
|
|
389
|
+
# is absent, empty, or — for images — has no URL.
|
|
390
|
+
#
|
|
391
|
+
# **Image path does not validate here.** Validation runs once,
|
|
392
|
+
# inside the provider's `embed_image` call. Validating here
|
|
393
|
+
# would double-resolve every URL (round-2 audit LOW #3) since
|
|
394
|
+
# provider implementations call `validate_image_url!` again.
|
|
395
|
+
# The digest is computed from the raw URL string, which is fine
|
|
396
|
+
# — the digest is a content fingerprint, not a security boundary.
|
|
397
|
+
# If validation fails, the provider raises `InvalidImageURL` /
|
|
398
|
+
# `ConfirmationRequired` from inside `recompute_embedding!`, which
|
|
399
|
+
# surfaces from `before_save` exactly as before.
|
|
400
|
+
def self.build_source_input(record, directive)
|
|
401
|
+
if directive.image?
|
|
402
|
+
source_field = directive.sources.first
|
|
403
|
+
file = record.public_send(source_field)
|
|
404
|
+
return nil if file.nil?
|
|
405
|
+
url = file.respond_to?(:url) ? file.url : nil
|
|
406
|
+
return nil if url.nil? || url.to_s.empty?
|
|
407
|
+
url.to_s
|
|
408
|
+
else
|
|
409
|
+
build_source_text(record, directive.sources)
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# @!visibility private
|
|
414
|
+
# Dispatch the provider call based on directive modality.
|
|
415
|
+
def self.call_provider(provider, directive, input)
|
|
416
|
+
if directive.image?
|
|
417
|
+
provider.embed_image([input],
|
|
418
|
+
input_type: directive.input_type,
|
|
419
|
+
allow_insecure: directive.allow_insecure ? true : false)
|
|
420
|
+
else
|
|
421
|
+
provider.embed_text([input], input_type: directive.input_type)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
276
425
|
# @!visibility private
|
|
277
426
|
# Concatenate source-field string values. `nil` and blank entries
|
|
278
427
|
# are skipped; remaining values are joined with a double newline.
|