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,214 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "monitor"
5
+
6
+ module Parse
7
+ module LiveQuery
8
+ # Monitors WebSocket connection health via ping/pong and activity tracking.
9
+ #
10
+ # Schedules periodic ping frames and detects stale connections when pong
11
+ # responses are not received within the configured timeout.
12
+ #
13
+ # @example
14
+ # monitor = HealthMonitor.new(client: client, ping_interval: 30.0, pong_timeout: 10.0)
15
+ # monitor.start
16
+ # # ... connection activity ...
17
+ # monitor.stop
18
+ #
19
+ class HealthMonitor
20
+ # Default ping interval in seconds
21
+ DEFAULT_PING_INTERVAL = 30.0
22
+
23
+ # Default pong timeout in seconds
24
+ DEFAULT_PONG_TIMEOUT = 10.0
25
+
26
+ # @return [Float] seconds between ping frames
27
+ attr_reader :ping_interval
28
+
29
+ # @return [Float] seconds to wait for pong response
30
+ attr_reader :pong_timeout
31
+
32
+ # @return [Time, nil] when connection was established
33
+ attr_reader :connection_established_at
34
+
35
+ # @return [Time, nil] last activity (any message received)
36
+ attr_reader :last_activity_at
37
+
38
+ # @return [Time, nil] last pong received
39
+ attr_reader :last_pong_at
40
+
41
+ # Create a new health monitor
42
+ # @param client [Client] the LiveQuery client to monitor
43
+ # @param ping_interval [Float] seconds between pings
44
+ # @param pong_timeout [Float] seconds to wait for pong
45
+ def initialize(client:, ping_interval: DEFAULT_PING_INTERVAL, pong_timeout: DEFAULT_PONG_TIMEOUT)
46
+ @client = client
47
+ @ping_interval = ping_interval
48
+ @pong_timeout = pong_timeout
49
+
50
+ @monitor = Monitor.new
51
+ @running = false
52
+ @ping_thread = nil
53
+ @awaiting_pong = false
54
+
55
+ @connection_established_at = nil
56
+ @last_activity_at = nil
57
+ @last_pong_at = nil
58
+ end
59
+
60
+ # Start the health monitoring thread
61
+ # @return [void]
62
+ def start
63
+ @monitor.synchronize do
64
+ return if @running
65
+
66
+ @running = true
67
+ @connection_established_at = Time.now
68
+ @last_activity_at = Time.now
69
+ @last_pong_at = Time.now
70
+ @awaiting_pong = false
71
+
72
+ @ping_thread = Thread.new { ping_loop }
73
+ @ping_thread.abort_on_exception = false
74
+
75
+ Logging.debug("Health monitor started", ping_interval: @ping_interval, pong_timeout: @pong_timeout)
76
+ end
77
+ end
78
+
79
+ # Stop the health monitoring thread
80
+ # @return [void]
81
+ def stop
82
+ @monitor.synchronize do
83
+ return unless @running
84
+
85
+ @running = false
86
+ @ping_thread&.kill
87
+ @ping_thread = nil
88
+ @awaiting_pong = false
89
+
90
+ Logging.debug("Health monitor stopped")
91
+ end
92
+ end
93
+
94
+ # Record that a pong was received
95
+ # @return [void]
96
+ def record_pong
97
+ @monitor.synchronize do
98
+ @last_pong_at = Time.now
99
+ @last_activity_at = Time.now
100
+ @awaiting_pong = false
101
+ end
102
+ Logging.debug("Pong received")
103
+ end
104
+
105
+ # Record that activity was received (any message)
106
+ # @return [void]
107
+ def record_activity
108
+ @monitor.synchronize do
109
+ @last_activity_at = Time.now
110
+ end
111
+ end
112
+
113
+ # Check if monitor is running
114
+ # @return [Boolean]
115
+ def running?
116
+ @monitor.synchronize { @running }
117
+ end
118
+
119
+ # Check if connection is stale (no pong within timeout)
120
+ # @return [Boolean]
121
+ def stale?
122
+ @monitor.synchronize do
123
+ return false unless @awaiting_pong
124
+ return false unless @last_pong_at
125
+
126
+ Time.now - @last_pong_at > (@ping_interval + @pong_timeout)
127
+ end
128
+ end
129
+
130
+ # Check if connection appears healthy
131
+ # @return [Boolean]
132
+ def healthy?
133
+ @monitor.synchronize do
134
+ return false unless @running
135
+ return true unless @last_activity_at
136
+
137
+ # Consider unhealthy if no activity for 2x ping interval + pong timeout
138
+ max_idle = (@ping_interval * 2) + @pong_timeout
139
+ Time.now - @last_activity_at < max_idle
140
+ end
141
+ end
142
+
143
+ # Seconds since last activity
144
+ # @return [Float, nil]
145
+ def seconds_since_activity
146
+ @monitor.synchronize do
147
+ return nil unless @last_activity_at
148
+ Time.now - @last_activity_at
149
+ end
150
+ end
151
+
152
+ # Seconds since last pong
153
+ # @return [Float, nil]
154
+ def seconds_since_pong
155
+ @monitor.synchronize do
156
+ return nil unless @last_pong_at
157
+ Time.now - @last_pong_at
158
+ end
159
+ end
160
+
161
+ # Get health information as a hash
162
+ # @return [Hash]
163
+ def health_info
164
+ @monitor.synchronize do
165
+ {
166
+ running: @running,
167
+ healthy: healthy?,
168
+ stale: stale?,
169
+ awaiting_pong: @awaiting_pong,
170
+ connection_established_at: @connection_established_at,
171
+ last_activity_at: @last_activity_at,
172
+ last_pong_at: @last_pong_at,
173
+ seconds_since_activity: seconds_since_activity,
174
+ seconds_since_pong: seconds_since_pong,
175
+ ping_interval: @ping_interval,
176
+ pong_timeout: @pong_timeout,
177
+ }
178
+ end
179
+ end
180
+
181
+ private
182
+
183
+ # Main ping loop - runs in background thread
184
+ def ping_loop
185
+ while @running
186
+ begin
187
+ sleep @ping_interval
188
+ break unless @running
189
+
190
+ # Send ping and mark as awaiting pong
191
+ @monitor.synchronize { @awaiting_pong = true }
192
+
193
+ Logging.debug("Sending ping")
194
+ @client.send(:send_ping)
195
+
196
+ # Wait for pong timeout
197
+ sleep @pong_timeout
198
+ break unless @running
199
+
200
+ # Check if pong was received
201
+ if @awaiting_pong
202
+ Logging.warn("Connection stale: no pong received", seconds_waited: @ping_interval + @pong_timeout)
203
+ @client.send(:handle_stale_connection)
204
+ break
205
+ end
206
+ rescue StandardError => e
207
+ Logging.error("Ping loop error", error: e)
208
+ break
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,149 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "logger"
5
+
6
+ module Parse
7
+ module LiveQuery
8
+ # Structured logging module for LiveQuery.
9
+ #
10
+ # Provides leveled logging with context support. Disabled by default.
11
+ #
12
+ # @example Enable logging
13
+ # Parse::LiveQuery::Logging.enabled = true
14
+ # Parse::LiveQuery::Logging.log_level = :debug
15
+ #
16
+ # @example Use custom logger
17
+ # Parse::LiveQuery::Logging.logger = Rails.logger
18
+ #
19
+ module Logging
20
+ # Log levels in order of verbosity
21
+ LEVELS = [:debug, :info, :warn, :error].freeze
22
+
23
+ class << self
24
+ # @return [Boolean] whether logging is enabled
25
+ attr_accessor :enabled
26
+
27
+ # @return [Logger, nil] custom logger instance
28
+ attr_accessor :logger
29
+
30
+ # @return [Symbol] current log level (:debug, :info, :warn, :error)
31
+ attr_reader :log_level
32
+
33
+ # Set log level with validation
34
+ # @param level [Symbol] one of :debug, :info, :warn, :error
35
+ def log_level=(level)
36
+ unless LEVELS.include?(level)
37
+ raise ArgumentError, "Invalid log level: #{level}. Must be one of #{LEVELS.inspect}"
38
+ end
39
+ @log_level = level
40
+ end
41
+
42
+ # Get or create the default logger
43
+ # @return [Logger]
44
+ def default_logger
45
+ @default_logger ||= begin
46
+ l = ::Logger.new($stdout)
47
+ l.progname = "Parse::LiveQuery"
48
+ l.formatter = proc do |severity, datetime, progname, msg|
49
+ "[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity} -- #{progname}: #{msg}\n"
50
+ end
51
+ l
52
+ end
53
+ end
54
+
55
+ # Get the current logger (custom or default)
56
+ # @return [Logger]
57
+ def current_logger
58
+ logger || default_logger
59
+ end
60
+
61
+ # Log a debug message
62
+ # @param message [String] the message
63
+ # @param context [Hash] optional context data
64
+ def debug(message, **context)
65
+ log(:debug, message, context)
66
+ end
67
+
68
+ # Log an info message
69
+ # @param message [String] the message
70
+ # @param context [Hash] optional context data
71
+ def info(message, **context)
72
+ log(:info, message, context)
73
+ end
74
+
75
+ # Log a warning message
76
+ # @param message [String] the message
77
+ # @param context [Hash] optional context data
78
+ def warn(message, **context)
79
+ log(:warn, message, context)
80
+ end
81
+
82
+ # Log an error message
83
+ # @param message [String] the message
84
+ # @param context [Hash] optional context data
85
+ def error(message, **context)
86
+ log(:error, message, context)
87
+ end
88
+
89
+ # Reset logging configuration to defaults
90
+ def reset!
91
+ @enabled = false
92
+ @logger = nil
93
+ @log_level = :info
94
+ @default_logger = nil
95
+ end
96
+
97
+ private
98
+
99
+ # Check if a level should be logged based on current log_level
100
+ # @param level [Symbol] the level to check
101
+ # @return [Boolean]
102
+ def should_log?(level)
103
+ return false unless enabled
104
+
105
+ current_level_index = LEVELS.index(@log_level || :info)
106
+ message_level_index = LEVELS.index(level)
107
+ message_level_index >= current_level_index
108
+ end
109
+
110
+ # Internal log method
111
+ # @param level [Symbol] log level
112
+ # @param message [String] the message
113
+ # @param context [Hash] context data
114
+ def log(level, message, context)
115
+ return unless should_log?(level)
116
+
117
+ formatted = if context.any?
118
+ "#{message} #{format_context(context)}"
119
+ else
120
+ message
121
+ end
122
+
123
+ current_logger.send(level, formatted)
124
+ end
125
+
126
+ # Format context hash for logging
127
+ # @param context [Hash] context data
128
+ # @return [String]
129
+ def format_context(context)
130
+ context.map do |k, v|
131
+ value = case v
132
+ when Exception
133
+ "#{v.class}: #{v.message}"
134
+ when String
135
+ v.length > 100 ? "#{v[0..97]}..." : v
136
+ else
137
+ v.inspect
138
+ end
139
+ "#{k}=#{value}"
140
+ end.join(" ")
141
+ end
142
+ end
143
+
144
+ # Initialize defaults
145
+ @enabled = false
146
+ @log_level = :info
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,294 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "monitor"
5
+
6
+ module Parse
7
+ module LiveQuery
8
+ # Represents an active subscription to a LiveQuery.
9
+ # Manages event callbacks and subscription lifecycle.
10
+ #
11
+ # @example
12
+ # subscription = Song.subscribe(where: { artist: "Beatles" })
13
+ #
14
+ # # Register callbacks using on() method
15
+ # subscription.on(:create) { |song| puts "New song!" }
16
+ # subscription.on(:update) { |song, original| puts "Updated!" }
17
+ #
18
+ # # Or use shorthand methods
19
+ # subscription.on_create { |song| puts "New song!" }
20
+ # subscription.on_update { |song, original| puts "Updated!" }
21
+ # subscription.on_delete { |song| puts "Deleted!" }
22
+ # subscription.on_enter { |song, original| puts "Entered query!" }
23
+ # subscription.on_leave { |song, original| puts "Left query!" }
24
+ #
25
+ # # Error handling
26
+ # subscription.on_error { |error| puts "Error: #{error.message}" }
27
+ #
28
+ # # Connection events
29
+ # subscription.on_subscribe { puts "Subscribed!" }
30
+ # subscription.on_unsubscribe { puts "Unsubscribed!" }
31
+ #
32
+ # # Cleanup
33
+ # subscription.unsubscribe
34
+ #
35
+ class Subscription
36
+ # Class-level monitor for request ID generation
37
+ @@id_monitor = Monitor.new
38
+ @@request_counter = 0
39
+
40
+ # @return [Integer] unique request ID for this subscription
41
+ attr_reader :request_id
42
+
43
+ # @return [String] Parse class name being subscribed to
44
+ attr_reader :class_name
45
+
46
+ # @return [Hash] the query constraints (where clause)
47
+ attr_reader :query
48
+
49
+ # @return [Parse::LiveQuery::Client] the LiveQuery client
50
+ attr_reader :client
51
+
52
+ # @return [Array<String>] fields to watch for changes (nil = all fields)
53
+ attr_reader :fields
54
+
55
+ # @return [String, nil] session token for ACL-aware subscriptions
56
+ attr_reader :session_token
57
+
58
+ # Create a new subscription
59
+ # @param client [Parse::LiveQuery::Client] the LiveQuery client
60
+ # @param class_name [String] Parse class name
61
+ # @param query [Hash] query constraints (where clause)
62
+ # @param fields [Array<String>, nil] specific fields to watch
63
+ # @param session_token [String, nil] session token for authentication
64
+ def initialize(client:, class_name:, query: {}, fields: nil, session_token: nil)
65
+ @monitor = Monitor.new
66
+ @client = client
67
+ @class_name = class_name
68
+ @query = query
69
+ @fields = fields
70
+ @session_token = session_token
71
+ @request_id = generate_request_id
72
+ @state = :pending
73
+ @callbacks = Hash.new { |h, k| h[k] = [] }
74
+
75
+ Logging.debug("Subscription created",
76
+ request_id: @request_id,
77
+ class_name: @class_name,
78
+ query_keys: @query.keys)
79
+ end
80
+
81
+ # Current subscription state
82
+ # @return [Symbol] :pending, :subscribed, :unsubscribed, or :error
83
+ def state
84
+ @monitor.synchronize { @state }
85
+ end
86
+
87
+ # Register a callback for a specific event type
88
+ # @param event_type [Symbol] :create, :update, :delete, :enter, :leave, :error, :subscribe, :unsubscribe
89
+ # @yield [object, original] block to call when event occurs
90
+ # @return [self]
91
+ def on(event_type, &block)
92
+ return self unless block_given?
93
+
94
+ @monitor.synchronize do
95
+ @callbacks[event_type.to_sym] << block
96
+ end
97
+ self
98
+ end
99
+
100
+ # Register callback for create events
101
+ # @yield [Parse::Object] the created object
102
+ # @return [self]
103
+ def on_create(&block)
104
+ on(:create, &block)
105
+ end
106
+
107
+ # Register callback for update events
108
+ # @yield [Parse::Object, Parse::Object] updated object, original object
109
+ # @return [self]
110
+ def on_update(&block)
111
+ on(:update, &block)
112
+ end
113
+
114
+ # Register callback for delete events
115
+ # @yield [Parse::Object] the deleted object
116
+ # @return [self]
117
+ def on_delete(&block)
118
+ on(:delete, &block)
119
+ end
120
+
121
+ # Register callback for enter events (object now matches query)
122
+ # @yield [Parse::Object, Parse::Object] current object, original object
123
+ # @return [self]
124
+ def on_enter(&block)
125
+ on(:enter, &block)
126
+ end
127
+
128
+ # Register callback for leave events (object no longer matches query)
129
+ # @yield [Parse::Object, Parse::Object] current object, original object
130
+ # @return [self]
131
+ def on_leave(&block)
132
+ on(:leave, &block)
133
+ end
134
+
135
+ # Register callback for errors
136
+ # @yield [Exception] the error that occurred
137
+ # @return [self]
138
+ def on_error(&block)
139
+ on(:error, &block)
140
+ end
141
+
142
+ # Register callback for successful subscription
143
+ # @yield called when subscription is confirmed
144
+ # @return [self]
145
+ def on_subscribe(&block)
146
+ on(:subscribe, &block)
147
+ end
148
+
149
+ # Register callback for unsubscription
150
+ # @yield called when unsubscribed
151
+ # @return [self]
152
+ def on_unsubscribe(&block)
153
+ on(:unsubscribe, &block)
154
+ end
155
+
156
+ # Unsubscribe from this subscription
157
+ # @return [Boolean] true if unsubscribe message was sent
158
+ def unsubscribe
159
+ @monitor.synchronize do
160
+ return false if @state == :unsubscribed
161
+ @state = :unsubscribed
162
+ end
163
+
164
+ Logging.debug("Unsubscribing", request_id: @request_id)
165
+ client.unsubscribe(self)
166
+ emit(:unsubscribe)
167
+ true
168
+ end
169
+
170
+ # @return [Boolean] true if currently subscribed
171
+ def subscribed?
172
+ state == :subscribed
173
+ end
174
+
175
+ # @return [Boolean] true if pending subscription confirmation
176
+ def pending?
177
+ state == :pending
178
+ end
179
+
180
+ # @return [Boolean] true if unsubscribed
181
+ def unsubscribed?
182
+ state == :unsubscribed
183
+ end
184
+
185
+ # @return [Boolean] true if in error state
186
+ def error?
187
+ state == :error
188
+ end
189
+
190
+ # Build the subscription message to send to the server
191
+ # @return [Hash]
192
+ def to_subscribe_message
193
+ msg = {
194
+ op: "subscribe",
195
+ requestId: request_id,
196
+ query: {
197
+ className: class_name,
198
+ where: query,
199
+ },
200
+ }
201
+
202
+ msg[:query][:fields] = fields if fields&.any?
203
+ msg[:sessionToken] = session_token if session_token
204
+
205
+ msg
206
+ end
207
+
208
+ # Build the unsubscribe message
209
+ # @return [Hash]
210
+ def to_unsubscribe_message
211
+ {
212
+ op: "unsubscribe",
213
+ requestId: request_id,
214
+ }
215
+ end
216
+
217
+ # Handle an incoming event from the server
218
+ # @param event [Parse::LiveQuery::Event]
219
+ # @api private
220
+ def handle_event(event)
221
+ Logging.debug("Handling event",
222
+ request_id: @request_id,
223
+ event_type: event.type)
224
+ emit(event.type, event.object, event.original)
225
+ end
226
+
227
+ # Mark subscription as confirmed by server
228
+ # @api private
229
+ def confirm!
230
+ @monitor.synchronize { @state = :subscribed }
231
+ Logging.info("Subscription confirmed",
232
+ request_id: @request_id,
233
+ class_name: @class_name)
234
+ emit(:subscribe)
235
+ end
236
+
237
+ # Mark subscription as failed with error
238
+ # @param error [Exception, String]
239
+ # @api private
240
+ def fail!(error)
241
+ @monitor.synchronize { @state = :error }
242
+ error = SubscriptionError.new(error) if error.is_a?(String)
243
+ Logging.error("Subscription failed",
244
+ request_id: @request_id,
245
+ error: error)
246
+ emit(:error, error)
247
+ end
248
+
249
+ # @return [Hash] subscription info as hash
250
+ def to_h
251
+ @monitor.synchronize do
252
+ {
253
+ request_id: request_id,
254
+ class_name: class_name,
255
+ query: query,
256
+ state: @state,
257
+ fields: fields,
258
+ }
259
+ end
260
+ end
261
+
262
+ private
263
+
264
+ # Emit an event to registered callbacks
265
+ # @param event_type [Symbol]
266
+ # @param args [Array] arguments to pass to callbacks
267
+ def emit(event_type, *args)
268
+ # Copy callbacks under lock, iterate outside to prevent deadlocks
269
+ callbacks = @monitor.synchronize { @callbacks[event_type].dup }
270
+
271
+ callbacks.each do |callback|
272
+ begin
273
+ callback.call(*args)
274
+ rescue => e
275
+ # Don't let callback errors break the subscription
276
+ Logging.error("Callback error",
277
+ request_id: @request_id,
278
+ event_type: event_type,
279
+ error: e)
280
+ emit(:error, e) unless event_type == :error
281
+ end
282
+ end
283
+ end
284
+
285
+ # Generate a unique request ID (thread-safe)
286
+ # @return [Integer]
287
+ def generate_request_id
288
+ @@id_monitor.synchronize do
289
+ @@request_counter += 1
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end