parse-stack-next 5.2.1 → 5.4.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +1 -0
  3. data/.gitignore +2 -0
  4. data/CHANGELOG.md +616 -0
  5. data/Gemfile +7 -0
  6. data/Gemfile.lock +12 -4
  7. data/README.md +296 -3
  8. data/Rakefile +243 -41
  9. data/docs/atlas_vector_search_guide.md +86 -2
  10. data/docs/client_sdk_guide.md +38 -0
  11. data/docs/mcp_guide.md +119 -4
  12. data/docs/mongodb_direct_guide.md +93 -1
  13. data/docs/usage_guide.md +11 -1
  14. data/docs/webhooks_guide.md +418 -0
  15. data/examples/README.md +46 -0
  16. data/examples/basic_client.rb +93 -0
  17. data/examples/basic_server.rb +109 -0
  18. data/examples/live_query_listener.rb +98 -0
  19. data/examples/rag_chatbot.rb +221 -0
  20. data/examples/webhook_server.rb +111 -0
  21. data/lib/parse/agent/mcp_rack_app.rb +285 -62
  22. data/lib/parse/agent/tools.rb +45 -5
  23. data/lib/parse/api/aggregate.rb +7 -1
  24. data/lib/parse/api/cloud_functions.rb +12 -4
  25. data/lib/parse/api/hooks.rb +46 -9
  26. data/lib/parse/api/objects.rb +16 -2
  27. data/lib/parse/api/path_segment.rb +33 -0
  28. data/lib/parse/api/server.rb +94 -0
  29. data/lib/parse/api/users.rb +58 -2
  30. data/lib/parse/atlas_search.rb +7 -7
  31. data/lib/parse/client/body_builder.rb +5 -0
  32. data/lib/parse/client/protocol.rb +4 -0
  33. data/lib/parse/client.rb +174 -9
  34. data/lib/parse/embeddings/spend_cap.rb +255 -0
  35. data/lib/parse/embeddings.rb +1 -0
  36. data/lib/parse/live_query/client.rb +3 -1
  37. data/lib/parse/live_query/subscription.rb +32 -5
  38. data/lib/parse/model/acl.rb +4 -2
  39. data/lib/parse/model/associations/belongs_to.rb +47 -0
  40. data/lib/parse/model/classes/audience.rb +52 -4
  41. data/lib/parse/model/classes/user.rb +200 -3
  42. data/lib/parse/model/core/embed_managed.rb +113 -0
  43. data/lib/parse/model/core/pluralized_aliases.rb +30 -0
  44. data/lib/parse/model/core/properties.rb +27 -0
  45. data/lib/parse/model/core/querying.rb +73 -1
  46. data/lib/parse/model/core/vector_searchable.rb +161 -0
  47. data/lib/parse/model/file.rb +35 -2
  48. data/lib/parse/model/object.rb +28 -5
  49. data/lib/parse/mongodb.rb +7 -1
  50. data/lib/parse/pipeline_security.rb +5 -3
  51. data/lib/parse/query/constraints.rb +29 -0
  52. data/lib/parse/query.rb +265 -27
  53. data/lib/parse/retrieval/agent_tool.rb +49 -0
  54. data/lib/parse/retrieval/reranker/cohere.rb +218 -0
  55. data/lib/parse/retrieval/reranker.rb +157 -0
  56. data/lib/parse/retrieval/retriever.rb +110 -23
  57. data/lib/parse/stack/version.rb +1 -1
  58. data/lib/parse/stack.rb +173 -1
  59. data/lib/parse/two_factor_auth/user_extension.rb +123 -31
  60. data/lib/parse/vector_search/hybrid.rb +578 -0
  61. data/lib/parse/webhooks/payload.rb +399 -11
  62. data/lib/parse/webhooks/trigger_audit.rb +502 -0
  63. data/lib/parse/webhooks.rb +215 -3
  64. data/scripts/docker/Dockerfile.parse +5 -1
  65. data/scripts/docker/docker-compose.test.yml +31 -0
  66. data/scripts/docker/docker-compose.verifyemail.yml +4 -0
  67. data/scripts/docker/preflight.sh +76 -0
  68. data/scripts/start-parse.sh +52 -4
  69. metadata +16 -1
@@ -0,0 +1,255 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "thread"
5
+
6
+ module Parse
7
+ module Embeddings
8
+ # Per-tenant cumulative embedding spend cap.
9
+ #
10
+ # The agent `semantic_search` tool embeds attacker-controlled text
11
+ # (chat queries) on every call. Without a cap, a tenant — or an
12
+ # adversary driving an agent — can run up unbounded embedding-provider
13
+ # cost. {SpendCap} tracks the cumulative number of *tokens* embedded
14
+ # per tenant inside a sliding time window and HARD-REFUSES (raises
15
+ # {Exceeded}) once a tenant would exceed its limit. This is distinct
16
+ # from {Parse::Agent::RateLimiter}, which bounds request *count* per
17
+ # window; the spend cap bounds embedding *volume* (a proxy for cost).
18
+ #
19
+ # == Disabled by default
20
+ #
21
+ # With no configured limit the cap is a no-op — {.charge!} records
22
+ # nothing and never raises. Operators opt in:
23
+ #
24
+ # Parse::Embeddings::SpendCap.configure(limit_tokens: 1_000_000, window: 3600)
25
+ # Parse::Embeddings::SpendCap.configure(:acme_tenant, limit_tokens: 50_000)
26
+ #
27
+ # A per-tenant limit (second form) overrides the default for that
28
+ # tenant. The reserved key {DEFAULT_KEY} sets the fallback applied to
29
+ # every tenant without an explicit limit.
30
+ #
31
+ # == Token estimation
32
+ #
33
+ # Callers pass an explicit token count, or use {.estimate_tokens} (a
34
+ # chars/4 heuristic — the same approximation the agent layer uses for
35
+ # its context-token budgets). The cap is intentionally an estimate: it
36
+ # exists to bound runaway cost, not to bill precisely.
37
+ #
38
+ # Thread-safe: all state lives behind a single mutex.
39
+ module SpendCap
40
+ # Raised when a tenant would exceed its token cap. Carries the
41
+ # limit, the already-used count (within the window), and a
42
+ # `retry_after` hint (seconds until enough of the window rolls off
43
+ # to admit the rejected charge — `nil` if the charge can never fit).
44
+ class Exceeded < StandardError
45
+ attr_reader :tenant_id, :limit, :used, :requested, :window, :retry_after
46
+
47
+ def initialize(tenant_id:, limit:, used:, requested:, window:, retry_after:)
48
+ @tenant_id = tenant_id
49
+ @limit = limit
50
+ @used = used
51
+ @requested = requested
52
+ @window = window
53
+ @retry_after = retry_after
54
+ super(
55
+ "Embedding spend cap exceeded for tenant #{tenant_id.inspect}: " \
56
+ "#{used}+#{requested} tokens would exceed #{limit}/#{window}s." \
57
+ "#{retry_after ? " Retry after #{retry_after.round(1)}s." : " Request exceeds the cap outright."}"
58
+ )
59
+ end
60
+ end
61
+
62
+ # Fallback bucket key for charges with no tenant identity, and the
63
+ # key under which {.configure} (with no explicit tenant) sets the
64
+ # default limit applied to every tenant lacking an override.
65
+ DEFAULT_KEY = :__default__
66
+
67
+ # Default sliding window (seconds) when none is configured.
68
+ DEFAULT_WINDOW = 3600
69
+
70
+ class << self
71
+ # Configure the cap. Two forms:
72
+ #
73
+ # configure(limit_tokens:, window:) # default for all tenants
74
+ # configure(tenant_id, limit_tokens:, window:) # override one tenant
75
+ #
76
+ # `limit_tokens: nil` disables the cap for that scope (the default
77
+ # scope when no tenant is given).
78
+ #
79
+ # @param tenant_id [Object, nil] tenant to override, or nil for
80
+ # the global default.
81
+ # @param limit_tokens [Integer, nil] token ceiling per window.
82
+ # @param window [Integer] sliding window length in seconds.
83
+ # @return [void]
84
+ def configure(tenant_id = nil, limit_tokens:, window: DEFAULT_WINDOW)
85
+ key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
86
+ unless limit_tokens.nil?
87
+ li = Integer(limit_tokens)
88
+ raise ArgumentError, "SpendCap: limit_tokens must be positive (got #{li})." if li <= 0
89
+ end
90
+ w = Integer(window)
91
+ raise ArgumentError, "SpendCap: window must be positive (got #{w})." if w <= 0
92
+ mutex.synchronize do
93
+ limits[key] = limit_tokens.nil? ? nil : { limit: Integer(limit_tokens), window: w }
94
+ end
95
+ nil
96
+ end
97
+
98
+ # Charge `tokens` against `tenant_id`'s budget. HARD-REFUSES by
99
+ # raising {Exceeded} when the charge would push the tenant over
100
+ # its limit within the window; otherwise records the charge and
101
+ # returns the new in-window total.
102
+ #
103
+ # No-op (returns nil) when no limit applies to the tenant.
104
+ #
105
+ # @param tenant_id [Object, nil] tenant identity (nil → {DEFAULT_KEY}).
106
+ # @param tokens [Integer] tokens to charge (>= 0).
107
+ # @return [Integer, nil] new in-window total, or nil if uncapped.
108
+ # @raise [Exceeded]
109
+ def charge!(tenant_id:, tokens:)
110
+ t = Integer(tokens)
111
+ raise ArgumentError, "SpendCap: tokens must be >= 0 (got #{t})." if t.negative?
112
+ key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
113
+
114
+ mutex.synchronize do
115
+ cfg = limit_for(key)
116
+ return nil if cfg.nil? # uncapped
117
+
118
+ window = cfg[:window]
119
+ limit = cfg[:limit]
120
+ now = monotonic
121
+ entries = prune(key, now, window)
122
+ used = entries.sum { |e| e[1] }
123
+
124
+ if used + t > limit
125
+ raise Exceeded.new(
126
+ tenant_id: key, limit: limit, used: used, requested: t,
127
+ window: window, retry_after: retry_after_for(entries, t, limit, window, now),
128
+ )
129
+ end
130
+ entries << [now, t] if t.positive?
131
+ used + t
132
+ end
133
+ end
134
+
135
+ # Current in-window token usage for a tenant (0 when uncapped or
136
+ # idle). Does not mutate.
137
+ #
138
+ # @param tenant_id [Object, nil]
139
+ # @return [Integer]
140
+ def usage(tenant_id: nil)
141
+ key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
142
+ mutex.synchronize do
143
+ cfg = limit_for(key)
144
+ return 0 if cfg.nil?
145
+ prune(key, monotonic, cfg[:window]).sum { |e| e[1] }
146
+ end
147
+ end
148
+
149
+ # Estimate token count from a String.
150
+ #
151
+ # The familiar "~4 characters per token" ratio only holds for
152
+ # ASCII. CJK, emoji, and other multibyte text run closer to one
153
+ # token per codepoint in a real tokenizer, so a pure
154
+ # `chars / 4` estimate undercounts such input by up to ~4x — and
155
+ # since this estimate is the sole basis for the hard-refuse
156
+ # decision, that lets a caller feeding multibyte text reach ~4x
157
+ # the real embedding volume before the cap trips. Take the larger
158
+ # of the char-based and byte-based estimates so multibyte input
159
+ # bills at least as much as its UTF-8 byte length implies.
160
+ #
161
+ # @param text [String]
162
+ # @return [Integer]
163
+ def estimate_tokens(text)
164
+ str = text.to_s
165
+ chars = (str.length / 4.0).ceil
166
+ bytes = (str.bytesize / 4.0).ceil
167
+ [chars, bytes].max
168
+ end
169
+
170
+ # Clear recorded usage (all tenants, or one). Limits are retained.
171
+ #
172
+ # @param tenant_id [Object, nil]
173
+ def reset!(tenant_id = nil)
174
+ mutex.synchronize do
175
+ if tenant_id.nil?
176
+ @buckets = {}
177
+ else
178
+ buckets.delete(tenant_id)
179
+ end
180
+ end
181
+ nil
182
+ end
183
+
184
+ # Remove all configured limits AND recorded usage. Mainly for
185
+ # tests — returns the cap to its disabled-by-default state.
186
+ def reset_all!
187
+ mutex.synchronize do
188
+ @limits = {}
189
+ @buckets = {}
190
+ end
191
+ nil
192
+ end
193
+
194
+ private
195
+
196
+ MUTEX_INIT = Mutex.new
197
+ private_constant :MUTEX_INIT
198
+
199
+ def mutex
200
+ @mutex ||= MUTEX_INIT.synchronize { @mutex ||= Mutex.new }
201
+ end
202
+
203
+ def limits
204
+ @limits ||= {}
205
+ end
206
+
207
+ def buckets
208
+ @buckets ||= {}
209
+ end
210
+
211
+ # Resolve the effective limit config for a key: an explicit
212
+ # per-tenant entry wins; otherwise the DEFAULT_KEY entry; nil when
213
+ # neither is set (uncapped). A key explicitly set to nil disables
214
+ # the cap for that tenant even if a default exists.
215
+ def limit_for(key)
216
+ if limits.key?(key)
217
+ limits[key]
218
+ else
219
+ limits[DEFAULT_KEY]
220
+ end
221
+ end
222
+
223
+ # Drop entries older than the window; returns the (mutated) live
224
+ # entry list for the key.
225
+ def prune(key, now, window)
226
+ entries = (buckets[key] ||= [])
227
+ cutoff = now - window
228
+ entries.reject! { |e| e[0] <= cutoff }
229
+ entries
230
+ end
231
+
232
+ # Seconds until enough in-window tokens roll off to admit a charge
233
+ # of `requested` tokens. nil when the request alone exceeds the
234
+ # limit (it can never fit).
235
+ def retry_after_for(entries, requested, limit, window, now)
236
+ return nil if requested > limit
237
+ need_to_free = (entries.sum { |e| e[1] } + requested) - limit
238
+ return 0.0 if need_to_free <= 0
239
+ freed = 0
240
+ entries.sort_by { |e| e[0] }.each do |ts, tok|
241
+ freed += tok
242
+ if freed >= need_to_free
243
+ return [(ts + window) - now, 0.0].max
244
+ end
245
+ end
246
+ nil
247
+ end
248
+
249
+ def monotonic
250
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -554,3 +554,4 @@ require_relative "embeddings/voyage"
554
554
  require_relative "embeddings/jina"
555
555
  require_relative "embeddings/qwen"
556
556
  require_relative "embeddings/local_http"
557
+ require_relative "embeddings/spend_cap"
@@ -363,7 +363,7 @@ module Parse
363
363
  # events can arrive. Optional — callers may still capture the
364
364
  # returned subscription and register callbacks later.
365
365
  # @return [Subscription]
366
- def subscribe(class_name, where: {}, fields: nil, session_token: nil,
366
+ def subscribe(class_name, where: {}, fields: nil, keys: nil, watch: nil, session_token: nil,
367
367
  use_master_key: false, &block)
368
368
  # Handle Parse::Object subclass
369
369
  if class_name.is_a?(Class) && class_name < Parse::Object
@@ -396,6 +396,8 @@ module Parse
396
396
  class_name: class_name,
397
397
  query: where,
398
398
  fields: fields,
399
+ keys: keys,
400
+ watch: watch,
399
401
  session_token: session_token,
400
402
  use_master_key: use_master_key,
401
403
  )
@@ -62,12 +62,22 @@ module Parse
62
62
  # @return [Parse::LiveQuery::Client] the LiveQuery client
63
63
  attr_reader :client
64
64
 
65
- # @return [Array<String>] fields to watch for changes (nil = all fields)
65
+ # @return [Array<String>] field projection for returned events
66
+ # (nil = all fields). Parse Server 7.0 renamed this subscription
67
+ # option from `fields` to `keys`; {#keys} is the canonical alias.
66
68
  attr_reader :fields
69
+ # @return [Array<String>] alias for {#fields} under Parse Server's
70
+ # post-7.0 `keys` name.
71
+ alias_method :keys, :fields
67
72
 
68
73
  # @return [String, nil] session token for ACL-aware subscriptions
69
74
  attr_reader :session_token
70
75
 
76
+ # @return [Array<String>, nil] field names that trigger update events when
77
+ # changed (PS 7.0+ `watch` option). +nil+ means all field changes trigger
78
+ # update events.
79
+ attr_reader :watch
80
+
71
81
  # Create a new subscription
72
82
  # @param client [Parse::LiveQuery::Client] the LiveQuery client
73
83
  # @param class_name [String] Parse class name
@@ -85,13 +95,16 @@ module Parse
85
95
  # elevated; on a non-admin connection the client warns and the
86
96
  # subscription stays ACL-scoped. For mixed scoped + admin needs,
87
97
  # use two separate clients. Defaults to false.
88
- def initialize(client:, class_name:, query: {}, fields: nil,
89
- session_token: nil, use_master_key: false)
98
+ def initialize(client:, class_name:, query: {}, fields: nil, keys: nil,
99
+ session_token: nil, use_master_key: false, watch: nil)
90
100
  @monitor = Monitor.new
91
101
  @client = client
92
102
  @class_name = class_name
93
103
  @query = query
94
- @fields = fields
104
+ # `keys` is the post-7.0 name; accept either and prefer the explicit
105
+ # `keys:` when both are supplied.
106
+ @fields = keys.nil? ? fields : keys
107
+ @watch = watch
95
108
  @session_token = session_token
96
109
  @use_master_key = use_master_key == true
97
110
  @request_id = generate_request_id
@@ -239,7 +252,21 @@ module Parse
239
252
  },
240
253
  }
241
254
 
242
- msg[:query][:fields] = fields if fields&.any?
255
+ if fields&.any?
256
+ # Parse Server 7.0 (DEPPS9 / #8852) renamed the subscription field-
257
+ # projection option from `fields` to `keys`. PS 7+ reads `keys` and
258
+ # ignores `fields`; PS < 7 reads `fields`. Emit BOTH so projection is
259
+ # honored on every supported server — sending an extra key the server
260
+ # ignores is harmless, while sending only `fields` silently disables
261
+ # projection (events return all columns) on PS 7+.
262
+ msg[:query][:keys] = fields
263
+ msg[:query][:fields] = fields
264
+ end
265
+ # PS 7.0 (#8028) `watch`: fire update events only when the named fields
266
+ # change. Distinct from field projection (`keys`/`fields`): `watch`
267
+ # controls which field mutations generate an update event; `keys` controls
268
+ # which fields are returned in the event payload.
269
+ msg[:query][:watch] = watch if watch&.any?
243
270
  msg[:sessionToken] = session_token if session_token
244
271
  # The subscribe frame deliberately NEVER carries `masterKey`.
245
272
  # Parse Server's `_handleSubscribe` does not read it — master-key
@@ -91,8 +91,10 @@ module Parse
91
91
  # artist.save
92
92
  #
93
93
  # You may also set default ACLs for your subclasses by using {Parse::Object.set_default_acl}.
94
- # These will be get applied for newly created instances. All subclasses have
95
- # public read and write enabled by default.
94
+ # These will get applied for newly created instances. Unless overridden, subclasses
95
+ # inherit the shipped `:owner_else_private` policy records are private
96
+ # (master-only) until an owner is resolved at save time. Use {Parse::Object.set_default_acl}
97
+ # or {Parse::Object.acl_policy} to grant broader access.
96
98
  #
97
99
  # class AdminData < Parse::Object
98
100
  #
@@ -119,6 +119,23 @@ module Parse
119
119
 
120
120
  # These items are added as attributes with the special data type of :pointer
121
121
  def belongs_to(key, opts = {})
122
+ # An explicitly-passed `as:` that names a scalar data type
123
+ # (`:string`, `:integer`, `:boolean`, …) is almost always a mistake —
124
+ # you cannot point at a scalar — and most often means a `property`
125
+ # was written with `as:` out of habit. Reject it at declaration time.
126
+ # This is the only association footgun decidable without all models
127
+ # loaded: an unresolved *class* name may simply be a forward
128
+ # reference (the target is required later), so that check is deferred
129
+ # to {Parse.validate_associations!}.
130
+ explicit_as = opts.key?(:as) ? opts[:as] : nil
131
+ if explicit_as && Parse::Properties::TYPES.include?(explicit_as.to_s.to_sym)
132
+ scalar = explicit_as.to_s.to_sym
133
+ raise ArgumentError,
134
+ "#{self}##{key}: `as: #{explicit_as.inspect}` names the reserved data type " \
135
+ ":#{scalar}, not a Parse class. For a scalar column write " \
136
+ "`property #{key.inspect}, #{scalar.inspect}`; if you really mean a Parse class " \
137
+ "named #{scalar.to_s.camelize.inspect}, pass `class_name: #{scalar.to_s.camelize.inspect}` instead."
138
+ end
122
139
  opts = { as: key, field: key.to_s.camelize(:lower), required: false }.merge(opts)
123
140
  # `opts[:class_name]` is the explicit target Parse class name; it takes
124
141
  # precedence over the legacy `as: :symbol` shorthand (where the
@@ -134,6 +151,22 @@ module Parse
134
151
  set_attribute_method = :"#{key}_set_attribute!"
135
152
 
136
153
  if self.fields[key].present? && Parse::Properties::BASE_FIELD_MAP[key].nil?
154
+ existing_type = self.fields[key]
155
+ # A structural redeclaration that CHANGES the type to a pointer
156
+ # (e.g. a field first declared `property :owner, :string` and then
157
+ # `property :owner, as: :user` / `belongs_to :owner`) is almost
158
+ # always a bug — and, because `property … as:` now delegates here,
159
+ # it is the same silent-String failure mode this whole feature
160
+ # exists to fix. Honor the same `strict_property_redefinition`
161
+ # contract that `property` enforces so the conflict is not
162
+ # downgraded to a warning. A same-type reopen (existing :pointer)
163
+ # still just warns, matching the prior behavior.
164
+ if existing_type != :pointer && Parse.strict_property_redefinition
165
+ raise ArgumentError,
166
+ "#{self}##{key} is already defined as :#{existing_type}; refusing to " \
167
+ "redeclare it as a :pointer association (target #{klassName}). Set " \
168
+ "Parse.strict_property_redefinition = false to fall back to warn-and-ignore."
169
+ end
137
170
  warn "Belongs relation #{self}##{key} already defined with type #{klassName}"
138
171
  return false
139
172
  end
@@ -151,6 +184,20 @@ module Parse
151
184
  # Mapping between local attribute name and the remote column name
152
185
  self.field_map.merge!(key => parse_field)
153
186
 
187
+ # Agent metadata: a belongs_to pointer can carry a semantic description
188
+ # (and per-value enum descriptions) just like a `property` can. This
189
+ # also lets `property :x, as: :user, _description: "..."` round-trip its
190
+ # metadata through the delegation in Parse::Properties#property.
191
+ if opts[:_description].present?
192
+ self.property_descriptions[key] = opts[:_description].to_s.freeze
193
+ end
194
+ if opts[:_enum].is_a?(Hash) && opts[:_enum].any?
195
+ normalized = opts[:_enum].each_with_object({}) do |(value, desc), h|
196
+ h[value.to_s] = desc.to_s.freeze
197
+ end
198
+ self.property_enum_descriptions[key] = normalized.freeze
199
+ end
200
+
154
201
  # used for dirty tracking
155
202
  define_attribute_methods key
156
203
 
@@ -155,12 +155,60 @@ module Parse
155
155
  property :name
156
156
 
157
157
  # @!attribute query
158
- # The query constraints that define which installations belong to this audience.
159
- # This is stored as a hash matching the Installation query format.
160
- # @return [Hash] The query constraint hash.
158
+ # The query constraints that define which installations belong to this
159
+ # audience, as a Hash matching the Installation query format.
160
+ #
161
+ # On the wire this is persisted as a JSON **string**, matching Parse
162
+ # Server's built-in `_Audience.query` column (typed `String`, not Object).
163
+ # Assigning a Hash and reading a Hash back is handled transparently. The
164
+ # previous `property :query, :object` emitted a JSON object, so every save
165
+ # of a hash query was rejected by the server with a schema mismatch
166
+ # ("expected String but got Object").
167
+ # @return [ActiveSupport::HashWithIndifferentAccess, nil] the query hash.
161
168
  # @example
162
169
  # audience.query = { "deviceType" => "ios", "appVersion" => { "$gte" => "2.0" } }
163
- property :query, :object
170
+ property :query_json, :string, field: :query
171
+
172
+ # JSON-encode a Hash/Array assigned to the query field before the `:string`
173
+ # property coercion runs. Every assignment path — `query=`,
174
+ # `query_constraint=`, mass-assignment via `new(query:)`, and server
175
+ # hydration — funnels through `format_value`, so intercepting here (rather
176
+ # than the public setter, which mass-assignment bypasses) is the single
177
+ # reliable place to keep the wire value valid JSON (`{"k":"v"}`) instead of
178
+ # Ruby's `Hash#to_s` (`{"k"=>"v"}`). A String passed in (e.g. a row loaded
179
+ # from the server) falls through to the normal string coercion untouched.
180
+ def format_value(key, val, data_type = nil)
181
+ if key == :query_json && (val.is_a?(Hash) || val.is_a?(Array))
182
+ return val.to_json
183
+ end
184
+ super
185
+ end
186
+
187
+ # The query constraints as a Hash (decoded from the stored JSON string).
188
+ # @return [ActiveSupport::HashWithIndifferentAccess, nil]
189
+ def query
190
+ raw = query_json
191
+ case raw
192
+ when nil, "" then nil
193
+ when Hash then raw.with_indifferent_access
194
+ when String
195
+ decoded = begin
196
+ JSON.parse(raw)
197
+ rescue JSON::ParserError
198
+ nil
199
+ end
200
+ decoded.is_a?(Hash) ? decoded.with_indifferent_access : decoded
201
+ else
202
+ raw
203
+ end
204
+ end
205
+
206
+ # Assign the audience query. Accepts a Hash (preferred) or a pre-encoded
207
+ # JSON String; either form is persisted as a JSON string.
208
+ # @param value [Hash, String, nil]
209
+ def query=(value)
210
+ self.query_json = value
211
+ end
164
212
 
165
213
  # Alias for query to match Parse Server naming conventions.
166
214
  # @return [Hash] The query constraint hash.