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,434 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ # A cursor-based pagination iterator for efficiently traversing large datasets.
6
+ #
7
+ # Unlike skip/offset pagination which becomes increasingly slow for large datasets,
8
+ # cursor-based pagination uses the last seen objectId to efficiently fetch the next page.
9
+ # This approach maintains consistent performance regardless of how deep into the dataset
10
+ # you paginate.
11
+ #
12
+ # @example Basic usage with each_page
13
+ # cursor = Song.cursor(limit: 100, order: :created_at.desc)
14
+ # cursor.each_page do |page|
15
+ # process(page)
16
+ # end
17
+ #
18
+ # @example Using each to iterate over individual items
19
+ # Song.cursor(limit: 50).each do |song|
20
+ # puts song.title
21
+ # end
22
+ #
23
+ # @example With constraints
24
+ # cursor = Song.cursor(artist: "Artist Name", limit: 25)
25
+ # cursor.each_page { |page| process(page) }
26
+ #
27
+ # @example Manual pagination control
28
+ # cursor = User.cursor(limit: 100)
29
+ # first_page = cursor.next_page
30
+ # second_page = cursor.next_page
31
+ # cursor.reset! # Start over from the beginning
32
+ #
33
+ class Cursor
34
+ include Enumerable
35
+
36
+ # Maximum page size allowed (Parse Server limit)
37
+ MAX_PAGE_SIZE = 1000
38
+
39
+ # Default page size
40
+ DEFAULT_PAGE_SIZE = 100
41
+
42
+ # @return [Parse::Query] the base query for this cursor
43
+ attr_reader :query
44
+
45
+ # @return [Integer] the number of items per page
46
+ attr_reader :page_size
47
+
48
+ # @return [String, nil] the current cursor position (objectId of last item)
49
+ attr_reader :position
50
+
51
+ # @return [Integer] the number of pages fetched so far
52
+ attr_reader :pages_fetched
53
+
54
+ # @return [Integer] the total number of items fetched so far
55
+ attr_reader :items_fetched
56
+
57
+ # @return [Symbol] the field to order by for cursor positioning
58
+ attr_reader :order_field
59
+
60
+ # @return [Symbol] the order direction (:asc or :desc)
61
+ attr_reader :order_direction
62
+
63
+ # Create a new cursor-based paginator.
64
+ #
65
+ # @param query [Parse::Query] the base query to paginate
66
+ # @param limit [Integer] the number of items per page (default: 100, max: 1000)
67
+ # @param order [Parse::Order, Symbol] the ordering for pagination.
68
+ # Defaults to :created_at.asc for stable ordering.
69
+ # Note: cursor pagination requires a stable sort order.
70
+ # @raise [ArgumentError] if limit exceeds MAX_PAGE_SIZE
71
+ def initialize(query, limit: DEFAULT_PAGE_SIZE, order: nil)
72
+ @query = query.dup
73
+ @page_size = validate_page_size(limit)
74
+ @position = nil
75
+ @pages_fetched = 0
76
+ @items_fetched = 0
77
+ @exhausted = false
78
+
79
+ # Set up ordering - cursor pagination needs a stable order
80
+ setup_ordering(order)
81
+ end
82
+
83
+ # Validate and normalize the page size.
84
+ # @param limit [Integer] the requested page size
85
+ # @return [Integer] the validated page size
86
+ # @raise [ArgumentError] if limit exceeds MAX_PAGE_SIZE
87
+ def validate_page_size(limit)
88
+ size = [limit.to_i, 1].max
89
+
90
+ if size > MAX_PAGE_SIZE
91
+ raise ArgumentError, "Page size #{size} exceeds maximum allowed (#{MAX_PAGE_SIZE}). " \
92
+ "Parse Server limits queries to #{MAX_PAGE_SIZE} results."
93
+ end
94
+
95
+ size
96
+ end
97
+
98
+ private :validate_page_size
99
+
100
+ # Check if more pages are available.
101
+ # @return [Boolean] true if more pages may be available
102
+ def more_pages?
103
+ !@exhausted
104
+ end
105
+
106
+ # Check if the cursor has been exhausted (no more results).
107
+ # @return [Boolean] true if all results have been fetched
108
+ def exhausted?
109
+ @exhausted
110
+ end
111
+
112
+ # Fetch the next page of results.
113
+ # @return [Array<Parse::Object>] the next page of results
114
+ # @return [Array] empty array if no more results
115
+ def next_page
116
+ return [] if @exhausted
117
+
118
+ # Build the page query
119
+ page_query = build_page_query
120
+
121
+ # Execute the query
122
+ results = page_query.results
123
+
124
+ # Update state
125
+ if results.empty? || results.size < @page_size
126
+ @exhausted = true
127
+ end
128
+
129
+ unless results.empty?
130
+ @pages_fetched += 1
131
+ @items_fetched += results.size
132
+ @position = extract_cursor_position(results.last)
133
+ end
134
+
135
+ results
136
+ end
137
+
138
+ # Reset the cursor to the beginning.
139
+ # @return [self]
140
+ def reset!
141
+ @position = nil
142
+ @pages_fetched = 0
143
+ @items_fetched = 0
144
+ @exhausted = false
145
+ self
146
+ end
147
+
148
+ # Iterate over each page of results.
149
+ # @yield [Array<Parse::Object>] each page of results
150
+ # @return [self]
151
+ def each_page
152
+ return enum_for(:each_page) unless block_given?
153
+
154
+ while more_pages?
155
+ page = next_page
156
+ break if page.empty?
157
+ yield page
158
+ end
159
+
160
+ self
161
+ end
162
+
163
+ # Iterate over each individual item.
164
+ # This is provided for Enumerable compatibility.
165
+ # @yield [Parse::Object] each item in the result set
166
+ # @return [self]
167
+ def each(&block)
168
+ return enum_for(:each) unless block_given?
169
+
170
+ each_page do |page|
171
+ page.each(&block)
172
+ end
173
+
174
+ self
175
+ end
176
+
177
+ # Fetch all results at once.
178
+ # Use with caution on large datasets.
179
+ # @return [Array<Parse::Object>] all matching objects
180
+ def all
181
+ results = []
182
+ each_page { |page| results.concat(page) }
183
+ results
184
+ end
185
+
186
+ # Get current cursor statistics.
187
+ # @return [Hash] statistics about the cursor pagination
188
+ def stats
189
+ {
190
+ pages_fetched: @pages_fetched,
191
+ items_fetched: @items_fetched,
192
+ page_size: @page_size,
193
+ exhausted: @exhausted,
194
+ position: @position,
195
+ order_field: @order_field,
196
+ order_direction: @order_direction,
197
+ }
198
+ end
199
+
200
+ # Serialize the cursor state to a JSON string for persistence.
201
+ # Useful for background jobs that may be interrupted and resumed.
202
+ #
203
+ # @example Save cursor state for later
204
+ # cursor = Song.cursor(limit: 100)
205
+ # cursor.next_page
206
+ # state = cursor.serialize
207
+ # # Store state in Redis, database, etc.
208
+ #
209
+ # @example Resume in a background job
210
+ # state = redis.get("cursor:#{job_id}")
211
+ # cursor = Parse::Cursor.deserialize(state)
212
+ # cursor.each_page { |page| process(page) }
213
+ #
214
+ # @return [String] JSON string containing cursor state
215
+ def serialize
216
+ require "json"
217
+ state = {
218
+ class_name: @query.table,
219
+ constraints: @query.constraints(true),
220
+ page_size: @page_size,
221
+ position: @position,
222
+ last_order_value: serialize_value(@last_order_value),
223
+ last_object_id: @last_object_id,
224
+ pages_fetched: @pages_fetched,
225
+ items_fetched: @items_fetched,
226
+ exhausted: @exhausted,
227
+ order_field: @order_field,
228
+ order_direction: @order_direction,
229
+ version: 1, # For future compatibility
230
+ }
231
+ JSON.generate(state)
232
+ end
233
+
234
+ # Alias for serialize
235
+ # @return [String] JSON string containing cursor state
236
+ def to_json
237
+ serialize
238
+ end
239
+
240
+ # Deserialize a cursor from a previously serialized state.
241
+ #
242
+ # @param json_string [String] the serialized cursor state
243
+ # @return [Parse::Cursor] a cursor restored to the saved state
244
+ # @raise [ArgumentError] if the JSON is invalid or missing required fields
245
+ #
246
+ # @example Resume a cursor
247
+ # cursor = Parse::Cursor.deserialize(saved_state)
248
+ # cursor.each_page { |page| process(page) }
249
+ def self.deserialize(json_string)
250
+ require "json"
251
+ state = JSON.parse(json_string, symbolize_names: true)
252
+
253
+ # Validate required fields
254
+ required = [:class_name, :page_size, :order_field, :order_direction]
255
+ missing = required.select { |f| state[f].nil? }
256
+ unless missing.empty?
257
+ raise ArgumentError, "Invalid cursor state: missing #{missing.join(", ")}"
258
+ end
259
+
260
+ # Get the model class
261
+ klass = Parse::Model.find_class(state[:class_name])
262
+ unless klass
263
+ raise ArgumentError, "Unknown Parse class: #{state[:class_name]}"
264
+ end
265
+
266
+ # Rebuild the query
267
+ query = klass.query(state[:constraints] || {})
268
+
269
+ # Create the cursor with the original order
270
+ order = state[:order_direction].to_sym == :desc ?
271
+ state[:order_field].to_s.to_sym.desc :
272
+ state[:order_field].to_s.to_sym.asc
273
+
274
+ cursor = new(query, limit: state[:page_size], order: order)
275
+
276
+ # Restore state
277
+ cursor.instance_variable_set(:@position, state[:position])
278
+ cursor.instance_variable_set(:@last_order_value, deserialize_value(state[:last_order_value]))
279
+ cursor.instance_variable_set(:@last_object_id, state[:last_object_id])
280
+ cursor.instance_variable_set(:@pages_fetched, state[:pages_fetched] || 0)
281
+ cursor.instance_variable_set(:@items_fetched, state[:items_fetched] || 0)
282
+ cursor.instance_variable_set(:@exhausted, state[:exhausted] || false)
283
+
284
+ cursor
285
+ end
286
+
287
+ # Alias for deserialize
288
+ # @param json_string [String] the serialized cursor state
289
+ # @return [Parse::Cursor] a cursor restored to the saved state
290
+ def self.from_json(json_string)
291
+ deserialize(json_string)
292
+ end
293
+
294
+ private
295
+
296
+ # Serialize a value for JSON storage (handles dates, etc.)
297
+ def serialize_value(value)
298
+ case value
299
+ when DateTime, Time
300
+ { "__type" => "Date", "iso" => value.utc.iso8601(3) }
301
+ when Date
302
+ { "__type" => "Date", "iso" => value.to_datetime.utc.iso8601(3) }
303
+ else
304
+ value
305
+ end
306
+ end
307
+
308
+ # Deserialize a value from JSON storage
309
+ def self.deserialize_value(value)
310
+ return value unless value.is_a?(Hash) && value["__type"] == "Date"
311
+ DateTime.parse(value["iso"])
312
+ end
313
+
314
+ # Set up the ordering for cursor pagination.
315
+ # Cursor pagination requires a stable sort order.
316
+ def setup_ordering(order)
317
+ if order.nil?
318
+ # Default to created_at ascending for stable pagination
319
+ @order_field = :createdAt
320
+ @order_direction = :asc
321
+ @query.order(:created_at.asc)
322
+ elsif order.is_a?(Parse::Order)
323
+ @order_field = order.field.to_sym
324
+ @order_direction = order.direction
325
+ @query.clear(:order)
326
+ @query.order(order)
327
+ elsif order.respond_to?(:to_sym)
328
+ # Handle plain symbol like :created_at (without .desc/.asc)
329
+ order_obj = Parse::Order.new(order)
330
+ @order_field = order_obj.field.to_sym
331
+ @order_direction = order_obj.direction
332
+ @query.clear(:order)
333
+ @query.order(order)
334
+ else
335
+ @order_field = :createdAt
336
+ @order_direction = :asc
337
+ @query.order(:created_at.asc)
338
+ end
339
+
340
+ # Always add objectId as secondary sort for stability
341
+ # This ensures consistent ordering when primary sort values are equal
342
+ unless @order_field == :objectId
343
+ secondary_order = @order_direction == :desc ? :objectId.desc : :objectId.asc
344
+ @query.order(secondary_order)
345
+ end
346
+ end
347
+
348
+ # Build the query for the next page.
349
+ def build_page_query
350
+ page_query = @query.dup
351
+ page_query.limit(@page_size)
352
+
353
+ if @position && @last_order_value && @last_object_id
354
+ # Use composite cursor constraint to handle ties correctly:
355
+ # (field < last_value) OR (field = last_value AND objectId < last_id)
356
+ # This ensures no records are skipped when multiple records have the same order field value.
357
+ or_constraint = build_cursor_constraint
358
+ page_query.add_constraints([or_constraint])
359
+ end
360
+
361
+ page_query
362
+ end
363
+
364
+ # Build the OR constraint for cursor positioning.
365
+ # Returns: (field < last_value) OR (field = last_value AND objectId < last_id)
366
+ # for descending order, or the inverse for ascending.
367
+ def build_cursor_constraint
368
+ formatted_field = Parse::Query.format_field(@order_field)
369
+
370
+ if @order_direction == :desc
371
+ # Descending: (field < last_value) OR (field = last_value AND objectId < last_id)
372
+ clause1 = { formatted_field => { "$lt" => format_cursor_value(@last_order_value) } }
373
+ clause2 = {
374
+ formatted_field => format_cursor_value(@last_order_value),
375
+ "objectId" => { "$lt" => @last_object_id },
376
+ }
377
+ else
378
+ # Ascending: (field > last_value) OR (field = last_value AND objectId > last_id)
379
+ clause1 = { formatted_field => { "$gt" => format_cursor_value(@last_order_value) } }
380
+ clause2 = {
381
+ formatted_field => format_cursor_value(@last_order_value),
382
+ "objectId" => { "$gt" => @last_object_id },
383
+ }
384
+ end
385
+
386
+ Parse::Constraint::CompoundQueryConstraint.new(:or, [clause1, clause2])
387
+ end
388
+
389
+ # Format cursor value for use in constraint.
390
+ # Handles Date/Time objects that need ISO8601 formatting for Parse.
391
+ def format_cursor_value(value)
392
+ case value
393
+ when DateTime, Time
394
+ { "__type" => "Date", "iso" => value.utc.iso8601(3) }
395
+ when Date
396
+ { "__type" => "Date", "iso" => value.to_datetime.utc.iso8601(3) }
397
+ else
398
+ value
399
+ end
400
+ end
401
+
402
+ # Extract cursor position from the last item in a page.
403
+ def extract_cursor_position(item)
404
+ return nil unless item
405
+
406
+ # Store both the order field value and objectId for precise cursor positioning
407
+ @last_order_value = get_field_value(item, @order_field)
408
+ @last_object_id = item.id
409
+
410
+ item.id
411
+ end
412
+
413
+ # Get the value of a field from an item.
414
+ def get_field_value(item, field)
415
+ case field
416
+ when :createdAt, :created_at
417
+ item.created_at
418
+ when :updatedAt, :updated_at
419
+ item.updated_at
420
+ when :objectId, :id
421
+ item.id
422
+ else
423
+ # Try the field as a method
424
+ if item.respond_to?(field)
425
+ item.send(field)
426
+ elsif item.respond_to?(:attributes) && item.attributes[field.to_s]
427
+ item.attributes[field.to_s]
428
+ else
429
+ nil
430
+ end
431
+ end
432
+ end
433
+ end
434
+ end