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,382 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Core
6
+ # Operator-facing introspection mixin. Extended onto Parse::Object so
7
+ # `Model.describe` aggregates local model declarations, server schema,
8
+ # CLP, and Atlas Search index state into a single Hash.
9
+ #
10
+ # SECURITY POSTURE — mirrors {Parse::Agent::Describe}. This is
11
+ # operator-side observability, NOT data exposed to an LLM. Output is
12
+ # never included in tool responses, MCP `tools/list`, or any
13
+ # `parse.agent.*` notification payload. Surfacing it via a console or
14
+ # debug endpoint requires auth-gating on the operator boundary.
15
+ #
16
+ # Network policy mirrors `agent.describe`: local-only by default. Opt
17
+ # in to server fetches with `network: true`. Each section degrades
18
+ # gracefully (`{available: false, reason: ...}`) instead of raising
19
+ # when the underlying service is unreachable or unconfigured.
20
+ module Describe
21
+ LOCAL_SECTIONS = %i[model acl].freeze
22
+ NETWORK_SECTIONS = %i[schema clp atlas indexes].freeze
23
+ ALL_SECTIONS = (LOCAL_SECTIONS + NETWORK_SECTIONS).freeze
24
+
25
+ # Core/built-in field keys we don't report under `:model[:fields]` —
26
+ # they're inherited from Parse::Object (in both snake_case and
27
+ # camelCase form) and add noise to every output.
28
+ CORE_FIELD_KEYS = %i[
29
+ id object_id created_at updated_at acl session_token
30
+ objectId createdAt updatedAt ACL sessionToken
31
+ ].freeze
32
+
33
+ # Aggregate introspection for the class. Local-only by default; pass
34
+ # `network: true` to include server schema, CLP, and Atlas Search.
35
+ #
36
+ # @param sections [Array<Symbol>] which sections to include. When
37
+ # empty, returns LOCAL_SECTIONS for `network: false` and
38
+ # ALL_SECTIONS for `network: true`. Valid: :model :acl :schema
39
+ # :clp :atlas.
40
+ # @param pretty [Boolean] when true, returns a multi-line String for
41
+ # `puts` debugging instead of the Hash.
42
+ # @param network [Boolean] permit per-section server/Mongo fetches.
43
+ # When false, network sections short-circuit to
44
+ # `{available: false, reason: :network_disabled}`.
45
+ # @param client [Parse::Client, nil] optional client override for
46
+ # schema/clp fetches.
47
+ # @param master [Boolean] forward an explicit master-key opt-in to
48
+ # admin-only sub-fetches (currently: `$indexStats` via
49
+ # {Parse::MongoDB.index_stats}, which requires `master: true`).
50
+ # When false (default), `usage:` counters degrade to `{}` and the
51
+ # `indexes` section reports `usage_available: false`. Pass
52
+ # `master: true` from an operator/audit context to populate real
53
+ # counters. The flag is NEVER auto-set by the SDK.
54
+ # @note Valid sections: :model :acl :schema :clp :atlas :indexes.
55
+ # @return [Hash, String]
56
+ def describe(*sections, pretty: false, network: false, usage: false, master: false, client: nil)
57
+ requested = sections.flatten.map(&:to_sym)
58
+ active = if requested.empty?
59
+ network ? ALL_SECTIONS : LOCAL_SECTIONS
60
+ else
61
+ requested
62
+ end
63
+
64
+ data = { class_name: parse_class }
65
+ active.each do |s|
66
+ data[s] = describe_section(s, client: client, network: network, usage: usage, master: master)
67
+ end
68
+ pretty ? describe_pretty(data) : data
69
+ end
70
+
71
+ private
72
+
73
+ def describe_section(section, client:, network:, usage: false, master: false)
74
+ case section
75
+ when :model then describe_model_section
76
+ when :acl then describe_acl_section
77
+ when :schema then network_section(network) { describe_schema_section(client) }
78
+ when :clp then network_section(network) { describe_clp_section(client) }
79
+ when :atlas then network_section(network) { describe_atlas_section }
80
+ when :indexes then network_section(network) { describe_indexes_section(usage: usage, master: master) }
81
+ else { available: false, reason: :unknown_section }
82
+ end
83
+ end
84
+
85
+ def network_section(network)
86
+ return { available: false, reason: :network_disabled } unless network
87
+ yield
88
+ rescue StandardError => e
89
+ { available: false, reason: :error, error: e.class.name, message: e.message }
90
+ end
91
+
92
+ def describe_model_section
93
+ local_fields = fields.reject { |k, _| CORE_FIELD_KEYS.include?(k) }
94
+ {
95
+ parse_class: parse_class,
96
+ fields: local_fields,
97
+ field_count: local_fields.size,
98
+ references: (respond_to?(:references) ? references.dup : {}),
99
+ relations: (respond_to?(:relations) ? relations.dup : {}),
100
+ defaults: (respond_to?(:defaults_list) ? defaults_list.dup : []),
101
+ enums: (respond_to?(:enums) ? enums.dup : {}),
102
+ agent_fields: (respond_to?(:agent_field_allowlist) && agent_field_allowlist.any? ?
103
+ agent_field_allowlist.map(&:to_s).sort : nil),
104
+ agent_methods: agent_method_names_or_nil,
105
+ }
106
+ end
107
+
108
+ def agent_method_names_or_nil
109
+ return nil unless respond_to?(:agent_methods)
110
+ methods = agent_methods
111
+ return nil if methods.nil? || methods.empty?
112
+ methods.keys.map(&:to_s).sort
113
+ end
114
+
115
+ def describe_acl_section
116
+ defaults = respond_to?(:default_acls) ? default_acls : nil
117
+ {
118
+ default_acl: defaults.respond_to?(:as_json) ? defaults.as_json : nil,
119
+ default_acl_private: (respond_to?(:default_acl_private) ? !!default_acl_private : nil),
120
+ acl_policy: instance_variable_get(:@acl_policy_setting),
121
+ }
122
+ end
123
+
124
+ def describe_schema_section(client)
125
+ info = Parse::Schema.fetch(parse_class, client: client)
126
+ return { available: false, reason: :class_missing_on_server } if info.nil?
127
+ diff = Parse::Schema::SchemaDiff.new(self, info)
128
+ {
129
+ available: true,
130
+ in_sync: diff.in_sync?,
131
+ server_field_count: info.field_names.size,
132
+ missing_on_server: diff.missing_on_server,
133
+ missing_locally: diff.missing_locally,
134
+ type_mismatches: diff.type_mismatches,
135
+ }
136
+ end
137
+
138
+ def describe_clp_section(client)
139
+ info = Parse::Schema.fetch(parse_class, client: client)
140
+ return { available: false, reason: :class_missing_on_server } if info.nil?
141
+ { available: true, class_level_permissions: info.class_level_permissions }
142
+ end
143
+
144
+ def describe_atlas_section
145
+ unless defined?(Parse::MongoDB) && Parse::MongoDB.respond_to?(:enabled?) && Parse::MongoDB.enabled?
146
+ return { available: false, reason: :mongodb_not_enabled }
147
+ end
148
+ indexes = Parse::AtlasSearch::IndexManager.list_indexes(parse_class)
149
+ {
150
+ available: true,
151
+ count: indexes.size,
152
+ indexes: indexes.map { |i|
153
+ { name: i["name"],
154
+ status: i["status"],
155
+ queryable: i["queryable"] }
156
+ },
157
+ }
158
+ rescue => e
159
+ msg = e.message.to_s
160
+ reason = if defined?(Parse::AtlasSearch::NotAvailable) && e.is_a?(Parse::AtlasSearch::NotAvailable)
161
+ :atlas_not_available
162
+ else
163
+ :error
164
+ end
165
+ { available: false, reason: reason, error: e.class.name, message: msg }
166
+ end
167
+
168
+ def describe_indexes_section(usage: false, master: false)
169
+ unless defined?(Parse::MongoDB) && Parse::MongoDB.respond_to?(:enabled?) && Parse::MongoDB.enabled?
170
+ return { available: false, reason: :mongodb_not_enabled }
171
+ end
172
+ raw = Parse::MongoDB.indexes(parse_class)
173
+ # `Parse::MongoDB.index_stats` requires explicit `master: true`
174
+ # because `$indexStats` discloses cluster metadata; without the
175
+ # opt-in it rescues to `{}`, which surfaces as
176
+ # `usage_available: false` below. Forward the caller's `master:`
177
+ # so operator/audit callers can populate real counters.
178
+ stats = if usage
179
+ master ? Parse::MongoDB.index_stats(parse_class, master: true) : {}
180
+ else
181
+ {}
182
+ end
183
+ normalized = raw.map { |idx| normalize_index_entry(idx, stats: stats) }
184
+ result = {
185
+ available: true,
186
+ count: normalized.size,
187
+ indexes: normalized,
188
+ }
189
+ if usage
190
+ # Empty stats Hash means the role lacks clusterMonitor / Atlas
191
+ # restricts $indexStats; surface that so the operator can act
192
+ # on it rather than reading absent counters as "zero traffic".
193
+ result[:usage_available] = !stats.empty?
194
+ end
195
+
196
+ if respond_to?(:mongo_index_declarations) && mongo_index_declarations.any?
197
+ # Migrator surfaces declared/drift only when the class opted
198
+ # into the DSL — keeps the describe output clean for classes
199
+ # that have not adopted `mongo_index`. Plan is a Hash keyed by
200
+ # collection — surface the parent's parse_class plan as
201
+ # `declared/drift/capacity` for the simple single-collection
202
+ # case, and add a `relations:` sub-key listing per-collection
203
+ # plans for any `_Join:*` collections from
204
+ # `mongo_relation_index`.
205
+ plans = Parse::Schema::IndexMigrator.new(self).plan
206
+ base = parse_class
207
+ parent_plan = plans[base]
208
+ if parent_plan
209
+ result[:declared] = parent_plan[:declared].map { |d| describe_decl(d) }
210
+ result[:drift] = describe_drift(parent_plan)
211
+ result[:parse_managed] = parent_plan[:parse_managed]
212
+ result[:capacity] = describe_capacity(parent_plan)
213
+ end
214
+ join_plans = plans.reject { |k, _| k == base }
215
+ unless join_plans.empty?
216
+ result[:relations] = join_plans.each_with_object({}) do |(coll, p), h|
217
+ h[coll] = {
218
+ declared: p[:declared].map { |d| describe_decl(d) },
219
+ drift: describe_drift(p),
220
+ parse_managed: p[:parse_managed],
221
+ capacity: describe_capacity(p),
222
+ }
223
+ end
224
+ end
225
+ end
226
+
227
+ result
228
+ end
229
+
230
+ def describe_decl(decl)
231
+ { keys: decl[:keys], options: decl[:options], collection: decl[:collection] }
232
+ end
233
+
234
+ def describe_drift(plan)
235
+ {
236
+ to_create: plan[:to_create].map { |d| describe_decl(d) },
237
+ in_sync: plan[:in_sync].map { |d| describe_decl(d) },
238
+ orphans: plan[:orphans],
239
+ conflicts: plan[:conflicts],
240
+ }
241
+ end
242
+
243
+ def describe_capacity(plan)
244
+ {
245
+ used: plan[:capacity_used],
246
+ after: plan[:capacity_after],
247
+ remaining: plan[:capacity_remaining],
248
+ ok: plan[:capacity_ok],
249
+ }
250
+ end
251
+
252
+ # Pull out the operator-relevant fields and coerce BSON values into
253
+ # JSON-safe primitives so the hash can be `JSON.dump`'d without
254
+ # surprises. The driver returns BSON::ObjectId / BSON::Regexp::Raw
255
+ # inside `partialFilterExpression` for some index shapes.
256
+ #
257
+ # When `stats:` is supplied (from `$indexStats`), merges in the
258
+ # `usage:` sub-hash with `ops` and `since` counters for the index.
259
+ def normalize_index_entry(idx, stats: {})
260
+ name = idx["name"] || idx[:name]
261
+ entry = {
262
+ name: name,
263
+ implicit_id: name == "_id_",
264
+ key: coerce_bson(idx["key"] || idx[:key] || {}),
265
+ unique: idx["unique"] == true,
266
+ sparse: idx["sparse"] == true,
267
+ partial_filter: coerce_bson(idx["partialFilterExpression"] || idx[:partialFilterExpression]),
268
+ expire_after_seconds: idx["expireAfterSeconds"] || idx[:expireAfterSeconds],
269
+ }
270
+ if (stat = stats[name])
271
+ entry[:usage] = { ops: stat[:ops], since: stat[:since] }
272
+ end
273
+ entry
274
+ end
275
+
276
+ def coerce_bson(value)
277
+ case value
278
+ when Hash
279
+ value.each_with_object({}) { |(k, v), h| h[k.to_s] = coerce_bson(v) }
280
+ when Array
281
+ value.map { |v| coerce_bson(v) }
282
+ when Symbol, String, Numeric, TrueClass, FalseClass, NilClass
283
+ value
284
+ else
285
+ value.to_s
286
+ end
287
+ end
288
+
289
+ def describe_pretty(data)
290
+ lines = ["#{data[:class_name]} describe:"]
291
+
292
+ if (m = data[:model])
293
+ lines << " fields: #{m[:field_count]}"
294
+ lines << " references: #{m[:references].inspect}" if m[:references].any?
295
+ lines << " relations: #{m[:relations].inspect}" if m[:relations].any?
296
+ lines << " defaults: #{m[:defaults].inspect}" if m[:defaults].any?
297
+ lines << " enums: #{m[:enums].keys.inspect}" if m[:enums].any?
298
+ lines << " agent_fields: #{m[:agent_fields].inspect}" if m[:agent_fields]
299
+ lines << " agent_methods: #{m[:agent_methods].inspect}" if m[:agent_methods]
300
+ end
301
+
302
+ if (a = data[:acl])
303
+ lines << " default_acl: #{a[:default_acl].inspect}"
304
+ lines << " acl_policy: #{a[:acl_policy].inspect}" if a[:acl_policy]
305
+ end
306
+
307
+ if (s = data[:schema])
308
+ if s[:available]
309
+ label = s[:in_sync] ? "in sync" : "drifted"
310
+ lines << " schema: #{label} (server fields=#{s[:server_field_count]})"
311
+ lines << " missing_on_server: #{s[:missing_on_server].keys.inspect}" if s[:missing_on_server].any?
312
+ lines << " missing_locally: #{s[:missing_locally].keys.inspect}" if s[:missing_locally].any?
313
+ lines << " type_mismatches: #{s[:type_mismatches].keys.inspect}" if s[:type_mismatches].any?
314
+ else
315
+ lines << " schema: unavailable (#{s[:reason]})"
316
+ end
317
+ end
318
+
319
+ if (c = data[:clp])
320
+ if c[:available]
321
+ lines << " clp: #{c[:class_level_permissions].keys.inspect}"
322
+ else
323
+ lines << " clp: unavailable (#{c[:reason]})"
324
+ end
325
+ end
326
+
327
+ if (x = data[:atlas])
328
+ if x[:available]
329
+ lines << " atlas_search: #{x[:count]} index(es)"
330
+ x[:indexes].each { |i| lines << " - #{i[:name]} (#{i[:status]}, queryable=#{i[:queryable]})" }
331
+ else
332
+ lines << " atlas_search: unavailable (#{x[:reason]})"
333
+ end
334
+ end
335
+
336
+ if (ix = data[:indexes])
337
+ if ix[:available]
338
+ lines << " indexes: #{ix[:count]}"
339
+ ix[:indexes].each do |i|
340
+ flags = []
341
+ flags << "unique" if i[:unique]
342
+ flags << "sparse" if i[:sparse]
343
+ flags << "ttl=#{i[:expire_after_seconds]}" if i[:expire_after_seconds]
344
+ flags << "_id" if i[:implicit_id]
345
+ flags << "ops=#{i[:usage][:ops]}" if i[:usage]
346
+ suffix = flags.any? ? " [#{flags.join(", ")}]" : ""
347
+ lines << " - #{i[:name]} #{i[:key].inspect}#{suffix}"
348
+ end
349
+ if ix.key?(:usage_available)
350
+ lines << " usage: #{ix[:usage_available] ? "available" : "unavailable (role lacks clusterMonitor)"}"
351
+ end
352
+ if (drift = ix[:drift])
353
+ lines << " declared: #{ix[:declared].size}"
354
+ lines << " to_create: #{drift[:to_create].size}" if drift[:to_create].any?
355
+ lines << " in_sync: #{drift[:in_sync].size}" if drift[:in_sync].any?
356
+ lines << " orphans: #{drift[:orphans].inspect}" if drift[:orphans].any?
357
+ lines << " conflicts: #{drift[:conflicts].size}" if drift[:conflicts].any?
358
+ end
359
+ if (cap = ix[:capacity])
360
+ lines << " capacity: #{cap[:used]}/#{Parse::Core::Indexing::MAX_INDEXES_PER_COLLECTION} (#{cap[:remaining]} remaining)"
361
+ end
362
+ if (relations = ix[:relations])
363
+ lines << " relation_indexes:"
364
+ relations.each do |coll, info|
365
+ lines << " #{coll}"
366
+ lines << " declared: #{info[:declared].size}"
367
+ d = info[:drift]
368
+ lines << " to_create: #{d[:to_create].size}" if d[:to_create].any?
369
+ lines << " in_sync: #{d[:in_sync].size}" if d[:in_sync].any?
370
+ lines << " orphans: #{d[:orphans].inspect}" if d[:orphans].any?
371
+ end
372
+ end
373
+ else
374
+ lines << " indexes: unavailable (#{ix[:reason]})"
375
+ end
376
+ end
377
+
378
+ lines.join("\n")
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,159 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Core
6
+ # Enhanced change tracking for Parse::Object that provides additional
7
+ # _was_changed? and enhanced _was methods for after_save hooks.
8
+ #
9
+ # This module adds _was_changed? methods that work correctly in after_save contexts
10
+ # by using previous_changes, while keeping normal _changed? methods intact.
11
+ #
12
+ # Key benefits:
13
+ # - _was_changed? methods work correctly in after_save hooks
14
+ # - _was methods return actual previous values (not current values) in after_save
15
+ # - Normal _changed? methods remain unchanged (standard ActiveModel behavior)
16
+ # - Automatically detects context using presence of previous_changes
17
+ #
18
+ # @example
19
+ # class Product < Parse::Object
20
+ # property :name, :string
21
+ # property :price, :float
22
+ #
23
+ # after_save :send_price_alert
24
+ #
25
+ # def send_price_alert
26
+ # if price_was_changed? && price_was < price
27
+ # AlertService.send("Price increased from $#{price_was} to $#{price}")
28
+ # end
29
+ # end
30
+ # end
31
+ module EnhancedChangeTracking
32
+ def self.included(base)
33
+ base.extend(ClassMethods)
34
+ end
35
+
36
+ module ClassMethods
37
+ # Override the property method to add enhanced change tracking
38
+ # after the ActiveModel methods are defined
39
+ def property(key, data_type = :string, **opts)
40
+ result = super # Call the original property method
41
+
42
+ # After property is defined, override the _changed? and _was methods
43
+ enhance_change_tracking_for_field(key)
44
+
45
+ result
46
+ end
47
+
48
+ private
49
+
50
+ # Create enhanced versions of _was_changed? and _was methods for a field
51
+ # @param field_name [Symbol] the field name to enhance
52
+ def enhance_change_tracking_for_field(field_name)
53
+ was_changed_method = "#{field_name}_was_changed?"
54
+ was_method = "#{field_name}_was"
55
+
56
+ # Store reference to original _was method if it exists
57
+ # Only alias if not already aliased (prevents infinite recursion)
58
+ original_was_method = "__original_#{was_method}".to_sym
59
+ if instance_method_defined?(was_method) && !instance_method_defined?(original_was_method)
60
+ alias_method original_was_method, was_method
61
+ end
62
+
63
+ # Define enhanced _was_changed? method (for after_save context)
64
+ define_method(was_changed_method) do
65
+ enhanced_field_changed?(field_name.to_s)
66
+ end
67
+
68
+ # Define enhanced _was method
69
+ define_method(was_method) do
70
+ enhanced_field_was(field_name.to_s)
71
+ end
72
+ end
73
+
74
+ # Check if an instance method is defined
75
+ # @param method_name [String, Symbol] the method name
76
+ # @return [Boolean] true if the method is defined
77
+ def instance_method_defined?(method_name)
78
+ method_defined?(method_name) || private_method_defined?(method_name)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ # Enhanced implementation of field_changed? that works in all contexts
85
+ # @param field_name [String] the name of the field to check
86
+ # @return [Boolean] true if the field was changed, false otherwise
87
+ def enhanced_field_changed?(field_name)
88
+ # In before_save context: use current changes (ActiveModel's changed? method)
89
+ # In after_save context: use previous_changes to see what was just changed
90
+ if in_after_save_context?
91
+ # Use previous_changes for after_save hooks
92
+ if previous_changes_available?
93
+ return previous_changes.key?(field_name.to_s)
94
+ end
95
+ else
96
+ # Use original ActiveModel method for before_save hooks and general usage
97
+ original_method = "__original_#{field_name}_changed?".to_sym
98
+ if respond_to?(original_method, true)
99
+ return send(original_method)
100
+ end
101
+
102
+ # Fallback: check if field is in current changes
103
+ return changed.include?(field_name.to_s) if respond_to?(:changed)
104
+ end
105
+
106
+ # Default fallback
107
+ false
108
+ end
109
+
110
+ # Enhanced implementation of field_was that works in all contexts
111
+ # @param field_name [String] the name of the field to get previous value for
112
+ # @return [Object] the previous value of the field
113
+ def enhanced_field_was(field_name)
114
+ # In after_save context: use previous_changes to get what was just changed
115
+ if in_after_save_context?
116
+ if previous_changes_available? && previous_changes[field_name.to_s]
117
+ return previous_changes[field_name.to_s][0] # [old_value, new_value]
118
+ end
119
+ else
120
+ # In before_save context: use original ActiveModel method for current operation
121
+ original_method = "__original_#{field_name}_was".to_sym
122
+ if respond_to?(original_method, true)
123
+ return send(original_method)
124
+ end
125
+
126
+ # Fallback: try to get from changes if field is currently changed
127
+ if respond_to?(:changes) && changes[field_name.to_s]
128
+ return changes[field_name.to_s][0] # [old_value, new_value]
129
+ end
130
+ end
131
+
132
+ # Default fallback to current value if no change detected
133
+ respond_to?(field_name) ? send(field_name) : nil
134
+ end
135
+
136
+ # Check if previous_changes is available and populated
137
+ # @return [Boolean] true if previous_changes is available
138
+ def previous_changes_available?
139
+ respond_to?(:previous_changes) &&
140
+ previous_changes.is_a?(Hash) &&
141
+ !previous_changes.empty?
142
+ end
143
+
144
+ # Detect if we're currently in an after_save context
145
+ # This is a heuristic based on the state of changes vs previous_changes
146
+ # @return [Boolean] true if likely in after_save context
147
+ def in_after_save_context?
148
+ # In after_save context:
149
+ # - previous_changes is populated (from the save that just completed)
150
+ # - current changes should be empty (cleared by successful save)
151
+ return false unless previous_changes_available?
152
+ return false unless respond_to?(:changed)
153
+
154
+ # If we have previous_changes but no current changes, we're likely in after_save
155
+ changed.empty?
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,38 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # The set of all Parse errors.
5
+ module Parse
6
+ # An abstract parent class for all Parse::Error types.
7
+ #
8
+ # Supports both legacy single-argument construction (`raise Parse::Error, "msg"`)
9
+ # and two-argument construction with a Parse error code
10
+ # (`raise Parse::Error.new(code, "msg")`). When a code is provided it is
11
+ # exposed via {#code} and prefixed onto the message.
12
+ class Error < StandardError
13
+ # @return [Integer, String, nil] the Parse error code when constructed with one.
14
+ attr_reader :code
15
+
16
+ def initialize(code_or_message = nil, message = nil)
17
+ if message.nil?
18
+ super(code_or_message)
19
+ else
20
+ @code = code_or_message
21
+ super("[#{code_or_message}] #{message}")
22
+ end
23
+ end
24
+ end
25
+
26
+ # Raised when attempting to access a field that was not fetched on a partially
27
+ # fetched object when autofetch has been disabled.
28
+ class UnfetchedFieldAccessError < Error
29
+ attr_reader :field_name, :object_class
30
+
31
+ def initialize(field_name, object_class)
32
+ @field_name = field_name
33
+ @object_class = object_class
34
+ super("Attempted to access unfetched field '#{field_name}' on #{object_class} with autofetch disabled. " \
35
+ "Either fetch the object first, include this field in the keys parameter, or enable autofetch.")
36
+ end
37
+ end
38
+ end