parse-stack-next 4.5.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,361 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "set"
5
+ require_relative "model/clp"
6
+
7
+ module Parse
8
+ module CLPScope
9
+ class Denied < StandardError
10
+ attr_reader :class_name, :operation
11
+
12
+ def initialize(class_name, operation, reason = nil)
13
+ @class_name = class_name
14
+ @operation = operation
15
+ super(reason || "CLP denied: #{operation} on #{class_name}")
16
+ end
17
+ end
18
+
19
+ OPERATIONS = %i[find count get create update delete].freeze
20
+
21
+ EMPTY_SET = Set.new.freeze
22
+ private_constant :EMPTY_SET
23
+
24
+ # Cache-entry shape. `kind:` is the disposition of the most recent
25
+ # schema-fetch attempt:
26
+ #
27
+ # - `:cached_clp` — schema fetch succeeded and returned a non-empty
28
+ # `classLevelPermissions` map. {permits?} evaluates against it.
29
+ # - `:no_clp` — schema fetch succeeded but the class has no CLP
30
+ # configured (or it's an empty hash). Parse Server treats this as
31
+ # public-default, so {permits?} returns true for all operations.
32
+ # - `:unresolvable` — schema fetch FAILED (network error, 5xx,
33
+ # unexpected exception, missing client). FAIL CLOSED:
34
+ # {permits?} returns false for every non-master query. Without
35
+ # this, a transient schema-endpoint outage would silently turn an
36
+ # admin-only class into public-readable for the duration of the
37
+ # outage — every mongo-direct caller was getting unfiltered rows
38
+ # because `permits?` returned true on `entry.nil?`.
39
+ #
40
+ # `fetched_at` is a monotonic clock reading used by {stale?} so
41
+ # the SDK isn't fooled by NTP adjustments.
42
+ #
43
+ # `clp` is `nil` for `:no_clp` and `:unresolvable`; readers must
44
+ # branch on `kind` before dereferencing.
45
+ CacheEntry = Struct.new(:kind, :clp, :fetched_at, keyword_init: true)
46
+
47
+ # Positive-cache TTL (seconds): how long a successful schema fetch
48
+ # is reused. Mirrors the previous module-level `@cache_ttl` knob;
49
+ # kept identical to preserve backwards-compatible cache behavior.
50
+ POSITIVE_TTL = 3600
51
+
52
+ # Negative-cache TTL (seconds): how long we remember that a class's
53
+ # schema was unresolvable. Short so a transient network blip doesn't
54
+ # gridlock the application for an hour, but non-zero so a permanent
55
+ # failure (auth credential rotated, schema endpoint disabled)
56
+ # doesn't melt the schema endpoint with a thundering herd of retries
57
+ # at request rate.
58
+ NEGATIVE_TTL = 5
59
+
60
+ @cache = {}
61
+ @cache_mutex = Mutex.new
62
+ @cache_ttl = POSITIVE_TTL
63
+
64
+ class << self
65
+ attr_accessor :cache_ttl, :schema_client
66
+
67
+ def permits?(class_name, op, permission_strings)
68
+ return true if permission_strings.nil? # master-key bypass
69
+ return true unless OPERATIONS.include?(op)
70
+
71
+ entry = fetch(class_name)
72
+ # `fetch` never returns nil now — it returns an `:unresolvable`
73
+ # CacheEntry on failure so callers must branch on `kind`.
74
+ case entry.kind
75
+ when :unresolvable
76
+ # FAIL CLOSED. The SDK is the only enforcement layer on the
77
+ # mongo-direct path; without a verified CLP we can't tell
78
+ # whether the class is public or admin-only, and the safe
79
+ # default is to refuse rather than silently surrender row
80
+ # filtering. Operators who want a different posture can
81
+ # pre-populate the cache via {.__cache_put} from a startup
82
+ # hook or static config.
83
+ warn_unresolvable_once!(class_name)
84
+ return false
85
+ when :no_clp
86
+ # Schema fetch succeeded; class has no CLP configured.
87
+ # Parse Server's default is public, so permit.
88
+ return true
89
+ end
90
+
91
+ op_map = entry.clp[op.to_s] || entry.clp[op]
92
+ # nil op_map: the operation has no CLP entry. Parse Server's
93
+ # default is public, so permit.
94
+ return true if op_map.nil?
95
+ # Empty op_map (`delete: {}` etc.): nobody but master-key.
96
+ # Master-key already short-circuited above, so deny here.
97
+ return false if op_map.is_a?(Hash) && op_map.empty?
98
+
99
+ claim_set = permission_strings.is_a?(Set) ? permission_strings : permission_strings.to_set
100
+
101
+ op_map.each do |principal, allowed|
102
+ case principal.to_s
103
+ when "*"
104
+ return true if allowed == true
105
+ when "requiresAuthentication"
106
+ return true if allowed == true && claim_set.any? { |e| user_identity?(e) }
107
+ when "pointerFields"
108
+ # Value is an Array of pointer field names, not a boolean.
109
+ # At the boundary, permit iff the claim set has a user
110
+ # identity to satisfy the constraint with; the actual
111
+ # row-by-row check runs post-fetch via {.pointer_fields_for}.
112
+ next if allowed.nil? || (allowed.respond_to?(:empty?) && allowed.empty?)
113
+ return true if claim_set.any? { |e| user_identity?(e) }
114
+ else
115
+ # Bare userObjectId or "role:Name" — claim-set match.
116
+ return true if allowed == true && claim_set.include?(principal.to_s)
117
+ end
118
+ end
119
+
120
+ false
121
+ end
122
+
123
+ def assert_permitted!(class_name, op, permission_strings)
124
+ return if permits?(class_name, op, permission_strings)
125
+ raise Denied.new(class_name, op,
126
+ "CLP refuses #{op} on '#{class_name}' for the current scope.")
127
+ end
128
+
129
+ def pointer_fields_for(class_name, op)
130
+ entry = fetch(class_name)
131
+ # No CLP at all, or schema unresolvable: there's no
132
+ # pointerFields constraint to apply. (For :unresolvable the
133
+ # caller's `permits?` already failed closed; this helper just
134
+ # returns nil so a post-fetch row-filter step is skipped.)
135
+ return nil if entry.kind == :no_clp || entry.kind == :unresolvable
136
+ op_map = entry.clp[op.to_s] || entry.clp[op]
137
+ return nil unless op_map.is_a?(Hash)
138
+ fields = op_map["pointerFields"] || op_map[:pointerFields]
139
+ return nil if fields.nil?
140
+ arr = Array(fields).map(&:to_s)
141
+ arr.empty? ? nil : arr
142
+ end
143
+
144
+ def protected_fields_for(class_name, permission_strings)
145
+ return EMPTY_SET if permission_strings.nil?
146
+
147
+ entry = fetch(class_name)
148
+ # No CLP / unresolvable: nothing to strip. For :unresolvable,
149
+ # `permits?` already refused the query, so this branch is only
150
+ # reached when callers ask for the protected-fields set directly
151
+ # (e.g. for documentation or audit tooling).
152
+ return EMPTY_SET if entry.kind == :no_clp || entry.kind == :unresolvable
153
+ protected_map = entry.clp["protectedFields"] || entry.clp[:protectedFields]
154
+ return EMPTY_SET if protected_map.nil? || protected_map.empty?
155
+
156
+ strip = Set.new(Array(protected_map["*"] || protected_map[:"*"]).map(&:to_s))
157
+
158
+ claim_set = permission_strings.is_a?(Set) ? permission_strings : permission_strings.to_set
159
+ claim_set.each do |claim|
160
+ next if claim == "*"
161
+ override = protected_map[claim.to_s] || protected_map[claim.to_sym]
162
+ next if override.nil?
163
+ override_set = Set.new(Array(override).map(&:to_s))
164
+ strip &= override_set
165
+ end
166
+
167
+ strip.freeze
168
+ end
169
+
170
+ def redact_protected_fields!(documents, strip_set)
171
+ return documents if documents.nil? || documents.empty?
172
+ return documents if strip_set.nil? || strip_set.empty?
173
+ documents.each { |doc| walk_and_delete!(doc, strip_set) }
174
+ documents
175
+ end
176
+
177
+ def filter_by_pointer_fields(documents, pointer_fields, user_id)
178
+ return documents if pointer_fields.nil? || pointer_fields.empty?
179
+ return [] if user_id.nil? || user_id.to_s.empty?
180
+ documents.select { |doc| any_pointer_matches?(doc, pointer_fields, user_id.to_s) }
181
+ end
182
+
183
+ def invalidate!(class_name)
184
+ @cache_mutex.synchronize { @cache.delete(class_name.to_s) }
185
+ nil
186
+ end
187
+
188
+ def reset_cache!
189
+ @cache_mutex.synchronize { @cache.clear }
190
+ # Also drop the unresolvable-class warned-once registry so
191
+ # tests that assert on `warn` emission for a class don't get
192
+ # silenced by an earlier test's call.
193
+ @warned_unresolvable_classes = Set.new
194
+ nil
195
+ end
196
+
197
+ def cache_stats
198
+ @cache_mutex.synchronize do
199
+ { size: @cache.size, class_names: @cache.keys.sort }
200
+ end
201
+ end
202
+
203
+ # Test/operator-facing hook: pre-populate the cache with a known
204
+ # CLP for `class_name`. An empty/nil `clp` is recorded as
205
+ # `:no_clp` (matches the public-default semantics Parse Server
206
+ # exposes when no CLP is configured); a non-empty `clp` is
207
+ # recorded as `:cached_clp` (the standard happy path).
208
+ def __cache_put(class_name, clp:)
209
+ normalized = clp || {}
210
+ kind = normalized.empty? ? :no_clp : :cached_clp
211
+ entry = CacheEntry.new(kind: kind, clp: normalized, fetched_at: monotonic_now)
212
+ @cache_mutex.synchronize { @cache[class_name.to_s] = entry }
213
+ entry
214
+ end
215
+
216
+ # Reset the unresolvable-class one-shot warning registry.
217
+ # Test-only — prevents warned-once state from leaking across
218
+ # the suite and silencing assertions on warning emission.
219
+ # @!visibility private
220
+ def reset_warning_state!
221
+ @warned_unresolvable_classes = Set.new
222
+ end
223
+
224
+ private
225
+
226
+ # Always returns a {CacheEntry}. On schema-fetch failure (network
227
+ # error, unsuccessful response, raised exception, missing client)
228
+ # the entry has `kind: :unresolvable` and is held for
229
+ # {NEGATIVE_TTL} seconds to suppress request-rate retries against
230
+ # an unhealthy schema endpoint without locking the application
231
+ # into a permanently-stale denial.
232
+ #
233
+ # An empty `class_name` short-circuits to an `:unresolvable`
234
+ # entry — `permits?` will refuse the call rather than dispatching
235
+ # `schema("")` to the upstream client.
236
+ def fetch(class_name)
237
+ key = class_name.to_s
238
+ return unresolvable_entry if key.empty?
239
+
240
+ cached = @cache_mutex.synchronize { @cache[key] }
241
+ return cached if cached && !stale?(cached)
242
+
243
+ client = schema_client || default_client_safe
244
+ entry =
245
+ if client.nil?
246
+ # No client configured (Parse.setup never called, etc.) —
247
+ # treat as unresolvable so we fail closed instead of
248
+ # crashing inside the begin block with NoMethodError.
249
+ unresolvable_entry
250
+ else
251
+ begin
252
+ response = client.schema(key)
253
+ if response&.success?
254
+ schema = response.result || {}
255
+ clp = schema["classLevelPermissions"] || {}
256
+ kind = clp.empty? ? :no_clp : :cached_clp
257
+ CacheEntry.new(kind: kind, clp: clp, fetched_at: monotonic_now)
258
+ else
259
+ unresolvable_entry
260
+ end
261
+ rescue StandardError
262
+ unresolvable_entry
263
+ end
264
+ end
265
+
266
+ @cache_mutex.synchronize { @cache[key] = entry }
267
+ entry
268
+ end
269
+
270
+ def unresolvable_entry
271
+ CacheEntry.new(kind: :unresolvable, clp: nil, fetched_at: monotonic_now)
272
+ end
273
+
274
+ # `stale?` is TTL-domain-aware: positive entries use `@cache_ttl`
275
+ # (defaults to {POSITIVE_TTL} = 3600s), unresolvable entries use
276
+ # the much shorter {NEGATIVE_TTL} (5s) so the next attempt can
277
+ # quickly recover from a transient failure. Both are evaluated in
278
+ # the same monotonic clock domain to avoid NTP-related drift.
279
+ def stale?(entry)
280
+ return false if entry.fetched_at.nil?
281
+ ttl = entry.kind == :unresolvable ? NEGATIVE_TTL : @cache_ttl
282
+ return false if ttl.nil?
283
+ return false if ttl.respond_to?(:infinite?) && ttl.infinite?
284
+ (monotonic_now - entry.fetched_at) > ttl
285
+ end
286
+
287
+ # `default_client` raises if no client is configured; wrap it so
288
+ # `fetch` can fall through to {unresolvable_entry} instead.
289
+ def default_client_safe
290
+ default_client
291
+ rescue StandardError
292
+ nil
293
+ end
294
+
295
+ # Emit a one-shot-per-class warning when `permits?` first denies
296
+ # because the schema is unresolvable. Without this, a quiet
297
+ # outage would silently break every scoped query; with it,
298
+ # operators see a single banner per class and can investigate.
299
+ def warn_unresolvable_once!(class_name)
300
+ @warned_unresolvable_classes ||= Set.new
301
+ key = class_name.to_s
302
+ return if @warned_unresolvable_classes.include?(key)
303
+ @warned_unresolvable_classes << key
304
+ warn "[Parse::CLPScope:SECURITY] schema for '#{key}' is " \
305
+ "unresolvable (network error, 5xx, missing client, or " \
306
+ "raised exception); FAILING CLOSED on all non-master " \
307
+ "queries for this class for the next #{NEGATIVE_TTL}s. " \
308
+ "Investigate the schema endpoint or pre-populate the " \
309
+ "cache via Parse::CLPScope.__cache_put. Subsequent " \
310
+ "denials for the same class will not re-warn."
311
+ end
312
+
313
+ def monotonic_now
314
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
315
+ end
316
+
317
+ def default_client
318
+ Parse::Client.client(:default)
319
+ end
320
+
321
+ def user_identity?(entry)
322
+ s = entry.to_s
323
+ s != "*" && !s.start_with?("role:")
324
+ end
325
+
326
+ def walk_and_delete!(node, strip_set)
327
+ case node
328
+ when Hash
329
+ strip_set.each { |k| node.delete(k) }
330
+ node.each_value { |v| walk_and_delete!(v, strip_set) }
331
+ when Array
332
+ node.each { |v| walk_and_delete!(v, strip_set) }
333
+ end
334
+ node
335
+ end
336
+
337
+ def any_pointer_matches?(doc, pointer_fields, user_id)
338
+ return false unless doc.is_a?(Hash)
339
+ pointer_fields.any? do |field|
340
+ val = doc[field] || doc[field.to_sym]
341
+ if val.is_a?(Hash)
342
+ return true if val["objectId"] == user_id || val[:objectId] == user_id
343
+ elsif val.is_a?(Array)
344
+ return true if val.any? do |v|
345
+ v.is_a?(Hash) && (v["objectId"] == user_id || v[:objectId] == user_id)
346
+ end
347
+ end
348
+ mongo_val = doc["_p_#{field}"] || doc[:"_p_#{field}"]
349
+ if mongo_val.is_a?(String) && mongo_val.include?("$")
350
+ _cls, oid = mongo_val.split("$", 2)
351
+ return true if oid == user_id
352
+ end
353
+ false
354
+ end
355
+ end
356
+ end
357
+
358
+ @cache_ttl = POSITIVE_TTL
359
+ @warned_unresolvable_classes = Set.new
360
+ end
361
+ end
@@ -0,0 +1,256 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "monitor"
5
+
6
+ module Parse
7
+ module LiveQuery
8
+ # Circuit breaker pattern for connection failure handling.
9
+ #
10
+ # Prevents repeated connection attempts when the server is unavailable,
11
+ # allowing time for recovery before retrying.
12
+ #
13
+ # States:
14
+ # - :closed - Normal operation, requests allowed
15
+ # - :open - Too many failures, requests blocked
16
+ # - :half_open - Testing if service recovered
17
+ #
18
+ # @example
19
+ # breaker = CircuitBreaker.new(failure_threshold: 5, reset_timeout: 60.0)
20
+ #
21
+ # if breaker.allow_request?
22
+ # begin
23
+ # connect_to_server
24
+ # breaker.record_success
25
+ # rescue => e
26
+ # breaker.record_failure
27
+ # end
28
+ # else
29
+ # # Circuit is open, wait before retrying
30
+ # end
31
+ #
32
+ class CircuitBreaker
33
+ # Valid circuit states
34
+ STATES = [:closed, :open, :half_open].freeze
35
+
36
+ # Default number of failures before opening circuit
37
+ DEFAULT_FAILURE_THRESHOLD = 5
38
+
39
+ # Default seconds before transitioning from open to half_open
40
+ DEFAULT_RESET_TIMEOUT = 60.0
41
+
42
+ # Default number of successful requests in half_open before closing
43
+ DEFAULT_HALF_OPEN_REQUESTS = 1
44
+
45
+ # @return [Symbol] current state (:closed, :open, :half_open)
46
+ attr_reader :state
47
+
48
+ # @return [Integer] number of consecutive failures
49
+ attr_reader :failure_count
50
+
51
+ # @return [Integer] number of successful requests in half_open
52
+ attr_reader :success_count
53
+
54
+ # @return [Time, nil] when the last failure occurred
55
+ attr_reader :last_failure_at
56
+
57
+ # @return [Integer] failure threshold before opening
58
+ attr_reader :failure_threshold
59
+
60
+ # @return [Float] seconds before half_open transition
61
+ attr_reader :reset_timeout
62
+
63
+ # Create a new circuit breaker
64
+ # @param failure_threshold [Integer] failures before opening circuit
65
+ # @param reset_timeout [Float] seconds before testing recovery
66
+ # @param half_open_requests [Integer] successes needed to close
67
+ # @param on_state_change [Proc, nil] callback for state changes
68
+ def initialize(failure_threshold: DEFAULT_FAILURE_THRESHOLD,
69
+ reset_timeout: DEFAULT_RESET_TIMEOUT,
70
+ half_open_requests: DEFAULT_HALF_OPEN_REQUESTS,
71
+ on_state_change: nil)
72
+ @failure_threshold = failure_threshold
73
+ @reset_timeout = reset_timeout
74
+ @half_open_requests = half_open_requests
75
+ @on_state_change = on_state_change
76
+
77
+ @monitor = Monitor.new
78
+ @state = :closed
79
+ @failure_count = 0
80
+ @success_count = 0
81
+ @last_failure_at = nil
82
+ end
83
+
84
+ # Check if a request is allowed
85
+ # @return [Boolean]
86
+ # @note Thread-safe. Callbacks are invoked outside the synchronized block.
87
+ def allow_request?
88
+ state_change = nil
89
+
90
+ result = @monitor.synchronize do
91
+ case @state
92
+ when :closed
93
+ true
94
+ when :open
95
+ if Time.now - @last_failure_at >= @reset_timeout
96
+ state_change = transition_to_internal(:half_open)
97
+ true
98
+ else
99
+ false
100
+ end
101
+ when :half_open
102
+ @success_count < @half_open_requests
103
+ end
104
+ end
105
+
106
+ # Invoke callback outside synchronized block to prevent deadlocks
107
+ notify_state_change(state_change) if state_change
108
+
109
+ result
110
+ end
111
+
112
+ # Record a successful request
113
+ # @return [void]
114
+ # @note Thread-safe. Callbacks are invoked outside the synchronized block.
115
+ def record_success
116
+ state_change = nil
117
+
118
+ @monitor.synchronize do
119
+ case @state
120
+ when :half_open
121
+ @success_count += 1
122
+ if @success_count >= @half_open_requests
123
+ Logging.info("Circuit breaker closing after successful recovery")
124
+ state_change = reset_internal!
125
+ end
126
+ when :closed
127
+ @failure_count = 0
128
+ end
129
+ end
130
+
131
+ # Invoke callback outside synchronized block to prevent deadlocks
132
+ notify_state_change(state_change) if state_change
133
+ end
134
+
135
+ # Record a failed request
136
+ # @return [void]
137
+ # @note Thread-safe. Callbacks are invoked outside the synchronized block.
138
+ def record_failure
139
+ state_change = nil
140
+
141
+ @monitor.synchronize do
142
+ @failure_count += 1
143
+ @last_failure_at = Time.now
144
+
145
+ case @state
146
+ when :closed
147
+ if @failure_count >= @failure_threshold
148
+ Logging.warn("Circuit breaker opening", failures: @failure_count)
149
+ state_change = transition_to_internal(:open)
150
+ end
151
+ when :half_open
152
+ Logging.warn("Circuit breaker re-opening from half_open")
153
+ state_change = transition_to_internal(:open)
154
+ end
155
+ end
156
+
157
+ # Invoke callback outside synchronized block to prevent deadlocks
158
+ notify_state_change(state_change) if state_change
159
+ end
160
+
161
+ # Reset the circuit breaker to closed state
162
+ # @return [void]
163
+ # @note Thread-safe. Callbacks are invoked outside the synchronized block.
164
+ def reset!
165
+ state_change = @monitor.synchronize { reset_internal! }
166
+
167
+ # Invoke callback outside synchronized block to prevent deadlocks
168
+ notify_state_change(state_change) if state_change
169
+ end
170
+
171
+ # Check if circuit is open (blocking requests)
172
+ # @return [Boolean]
173
+ def open?
174
+ @monitor.synchronize { @state == :open }
175
+ end
176
+
177
+ # Check if circuit is closed (allowing requests)
178
+ # @return [Boolean]
179
+ def closed?
180
+ @monitor.synchronize { @state == :closed }
181
+ end
182
+
183
+ # Check if circuit is half_open (testing recovery)
184
+ # @return [Boolean]
185
+ def half_open?
186
+ @monitor.synchronize { @state == :half_open }
187
+ end
188
+
189
+ # Seconds until circuit transitions to half_open
190
+ # @return [Float, nil] nil if not open
191
+ def time_until_half_open
192
+ @monitor.synchronize do
193
+ return nil unless @state == :open && @last_failure_at
194
+ remaining = @reset_timeout - (Time.now - @last_failure_at)
195
+ [remaining, 0].max
196
+ end
197
+ end
198
+
199
+ # Get circuit breaker info as hash
200
+ # @return [Hash]
201
+ def info
202
+ @monitor.synchronize do
203
+ {
204
+ state: @state,
205
+ failure_count: @failure_count,
206
+ success_count: @success_count,
207
+ failure_threshold: @failure_threshold,
208
+ reset_timeout: @reset_timeout,
209
+ last_failure_at: @last_failure_at,
210
+ time_until_half_open: time_until_half_open,
211
+ }
212
+ end
213
+ end
214
+
215
+ private
216
+
217
+ # Transition to a new state (must be called with mutex held)
218
+ # @param new_state [Symbol]
219
+ # @return [Array<Symbol, Symbol>, nil] [old_state, new_state] if changed, nil otherwise
220
+ def transition_to_internal(new_state)
221
+ old_state = @state
222
+ return nil if old_state == new_state
223
+
224
+ @state = new_state
225
+ @success_count = 0 if new_state == :half_open
226
+
227
+ Logging.debug("Circuit breaker state change", from: old_state, to: new_state)
228
+ [old_state, new_state]
229
+ end
230
+
231
+ # Reset internal state (must be called with mutex held)
232
+ # @return [Array<Symbol, Symbol>, nil] [old_state, :closed] if changed, nil otherwise
233
+ def reset_internal!
234
+ old_state = @state
235
+ @state = :closed
236
+ @failure_count = 0
237
+ @success_count = 0
238
+ @last_failure_at = nil
239
+
240
+ if old_state != :closed
241
+ Logging.debug("Circuit breaker reset", from: old_state, to: :closed)
242
+ [old_state, :closed]
243
+ end
244
+ end
245
+
246
+ # Notify state change callback outside of synchronized block
247
+ # @param state_change [Array<Symbol, Symbol>, nil] [old_state, new_state]
248
+ def notify_state_change(state_change)
249
+ return unless state_change && @on_state_change
250
+
251
+ old_state, new_state = state_change
252
+ @on_state_change.call(old_state, new_state)
253
+ end
254
+ end
255
+ end
256
+ end