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,162 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # RelationGraph derives the class-relationship graph from Parse Stack's
7
+ # existing `belongs_to` and `has_many :through => :relation` declarations,
8
+ # with no extra model DSL required. Each edge is a hash:
9
+ #
10
+ # { from:, to:, via:, cardinality:, kind: }
11
+ #
12
+ # `from`/`to` are Parse class names; `via` is the owning side's field path
13
+ # (`Post.author`); `cardinality` is `"1:N"` for pointer edges and `"N:M"`
14
+ # for relation columns; `kind` is `:belongs_to` or `:relation`.
15
+ #
16
+ # Convention: pointer edges are emitted from the target ("the one") to the
17
+ # source ("the many"), so `Post.author → _User` reads as
18
+ # `_User ─1:N→ Post (Post.author)` — natural English.
19
+ #
20
+ # @example Full graph
21
+ # Parse::Agent::RelationGraph.build
22
+ #
23
+ # @example Subset (both endpoints must be in the set)
24
+ # Parse::Agent::RelationGraph.build(classes: %w[_User Post])
25
+ #
26
+ # @example ASCII diagram for prompt text
27
+ # puts Parse::Agent::RelationGraph.to_ascii(
28
+ # Parse::Agent::RelationGraph.build
29
+ # )
30
+ #
31
+ module RelationGraph
32
+ extend self
33
+
34
+ # Conservative identifier shape used to sanitize edge components before
35
+ # rendering them into LLM-facing text. Edges sourced from gem-internal
36
+ # introspection should already match; the filter is defense in depth
37
+ # against any future code path that lets remote input into class/field
38
+ # naming (would otherwise be a prompt-injection channel).
39
+ SAFE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]{0,127}\z/.freeze
40
+ SAFE_VIA = %r{\A[A-Za-z_][A-Za-z0-9_]{0,127}\.[A-Za-z_][A-Za-z0-9_]{0,127}\z}.freeze
41
+
42
+ # System classes that participate in normal analytics queries and should
43
+ # remain visible by default. Other `_`-prefixed Parse internals are
44
+ # filtered out so the graph stays aligned with the `explore_database`
45
+ # prompt that already tells the LLM to skip them.
46
+ ANALYTICS_RELEVANT_SYSTEM_CLASSES = %w[_User _Role].freeze
47
+
48
+ # Build edges across the currently-loaded Parse model classes.
49
+ #
50
+ # When `classes:` is provided, only edges whose `from` AND `to` are both
51
+ # in the subset are returned (strict slice — keeps the diagram focused).
52
+ # Pass nil for the full graph.
53
+ #
54
+ # When MetadataRegistry has any `agent_visible` classes registered, only
55
+ # those are walked; otherwise all `Parse::Object` descendants are walked.
56
+ # Keeps the graph aligned with what the agent surfaces elsewhere.
57
+ #
58
+ # @param classes [Array<String>, nil] optional class-name subset
59
+ # @return [Array<Hash>] edge hashes
60
+ def build(classes: nil)
61
+ subset = classes && classes.map(&:to_s)
62
+ edges = []
63
+
64
+ candidate_classes.each do |klass|
65
+ next unless klass.respond_to?(:parse_class)
66
+ parse_class = klass.parse_class
67
+
68
+ if klass.respond_to?(:references)
69
+ klass.references.each do |field, target|
70
+ edges << {
71
+ from: target.to_s,
72
+ to: parse_class,
73
+ via: "#{parse_class}.#{field}",
74
+ cardinality: "1:N",
75
+ kind: :belongs_to,
76
+ }
77
+ end
78
+ end
79
+
80
+ if klass.respond_to?(:relations)
81
+ klass.relations.each do |key, target|
82
+ # has_many :through => :relation stores the Ruby key in
83
+ # `relations`, but `field_map` carries the on-the-wire camelCase
84
+ # column name (respecting an explicit `field:` override). The
85
+ # LLM needs the wire name to build `where:` / `include:` clauses
86
+ # against the actual column.
87
+ wire = klass.respond_to?(:field_map) ? (klass.field_map[key]&.to_s || key.to_s) : key.to_s
88
+ edges << {
89
+ from: parse_class,
90
+ to: target.to_s,
91
+ via: "#{parse_class}.#{wire}",
92
+ cardinality: "N:M",
93
+ kind: :relation,
94
+ }
95
+ end
96
+ end
97
+ end
98
+
99
+ edges.uniq! { |e| [e[:from], e[:to], e[:via]] }
100
+ return edges unless subset
101
+ edges.select { |e| subset.include?(e[:from]) && subset.include?(e[:to]) }
102
+ end
103
+
104
+ # Render edges as a compact ASCII diagram. Empty graph returns a
105
+ # one-line placeholder. Edges with components that don't match the
106
+ # SAFE_IDENTIFIER / SAFE_VIA shapes are dropped before rendering so the
107
+ # resulting text is always alphanumeric/dot-only — closes a theoretical
108
+ # prompt-injection channel if any future code path admits attacker
109
+ # influence into class or field names.
110
+ #
111
+ # @param edges [Array<Hash>] edge hashes from #build
112
+ # @return [String] aligned, one-edge-per-line diagram
113
+ def to_ascii(edges)
114
+ safe = edges.select do |e|
115
+ e[:from].to_s.match?(SAFE_IDENTIFIER) &&
116
+ e[:to].to_s.match?(SAFE_IDENTIFIER) &&
117
+ e[:via].to_s.match?(SAFE_VIA)
118
+ end
119
+ return "(no class relations defined)" if safe.empty?
120
+ max_from = safe.map { |e| e[:from].length }.max
121
+ max_to = safe.map { |e| e[:to].length }.max
122
+ safe.map do |e|
123
+ "#{e[:from].ljust(max_from)} ─#{e[:cardinality]}→ #{e[:to].ljust(max_to)} (#{e[:via]})"
124
+ end.join("\n")
125
+ end
126
+
127
+ # For a single Parse class, return its incoming and outgoing edges in a
128
+ # form suitable for embedding inside an enriched schema. Pass a
129
+ # pre-computed `edges` array to avoid re-walking the descendants on each
130
+ # call when enriching many schemas at once.
131
+ #
132
+ # @param class_name [String] Parse class name
133
+ # @param edges [Array<Hash>, nil] pre-built edges
134
+ # @return [Hash] `{outgoing: [...], incoming: [...]}`
135
+ def edges_for(class_name, edges = nil)
136
+ edges ||= build
137
+ {
138
+ outgoing: edges.select { |e| e[:from] == class_name },
139
+ incoming: edges.select { |e| e[:to] == class_name },
140
+ }
141
+ end
142
+
143
+ private
144
+
145
+ # Resolve the model classes to walk for graph building. When any class
146
+ # has opted in via `agent_visible`, use that explicit set (and trust the
147
+ # user's choice — system classes are allowed if marked visible).
148
+ # Otherwise default to all loaded Parse::Object descendants minus the
149
+ # `_`-prefixed Parse internals other than `_User`/`_Role` — matches the
150
+ # guidance in the `explore_database` prompt and prevents the relation
151
+ # graph from advertising `_Session`, `_Audience`, `_Idempotency`, etc.
152
+ def candidate_classes
153
+ return MetadataRegistry.visible_classes if MetadataRegistry.has_visible_classes?
154
+
155
+ Parse::Object.descendants.reject do |klass|
156
+ name = klass.respond_to?(:parse_class) ? klass.parse_class.to_s : klass.name.to_s
157
+ name.start_with?("_") && !ANALYTICS_RELEVANT_SYSTEM_CLASSES.include?(name)
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,453 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # The ResultFormatter transforms Parse API responses into
7
+ # LLM-friendly formats that are easy to understand and process.
8
+ #
9
+ # It provides consistent structure, human-readable type descriptions,
10
+ # and truncates large results to fit context windows.
11
+ #
12
+ module ResultFormatter
13
+ extend self
14
+
15
+ # Maximum number of results to include in output
16
+ MAX_RESULTS_DISPLAY = 50
17
+
18
+ # Parse field type mappings for human-readable output
19
+ TYPE_NAMES = {
20
+ "String" => "string",
21
+ "Number" => "number",
22
+ "Boolean" => "boolean",
23
+ "Date" => "date/time",
24
+ "Object" => "object (JSON)",
25
+ "Array" => "array",
26
+ "GeoPoint" => "geo location",
27
+ "File" => "file",
28
+ "Pointer" => "pointer (reference)",
29
+ "Relation" => "relation (many-to-many)",
30
+ "Bytes" => "binary data",
31
+ "Polygon" => "polygon (geo shape)",
32
+ "ACL" => "access control list",
33
+ }.freeze
34
+
35
+ # Format multiple schemas for display (compact summary)
36
+ # Returns class names grouped by type for efficient token usage.
37
+ # Use get_schema for detailed field info on specific classes.
38
+ #
39
+ # @param schemas [Array<Hash>] array of schema objects from Parse (enriched with metadata)
40
+ # @return [Hash] formatted schema summary
41
+ def format_schemas(schemas)
42
+ built_in = []
43
+ custom = []
44
+
45
+ schemas.each do |schema|
46
+ class_name = schema["className"]
47
+ fields = schema["fields"] || {}
48
+ agent_methods = schema["agent_methods"] || []
49
+
50
+ # Subtract the four system fields (objectId, createdAt, updatedAt,
51
+ # ACL) when reporting a "user-meaningful" count, but never let the
52
+ # subtraction go negative — the allowlist filter in enriched_schema
53
+ # may have already trimmed system fields out.
54
+ info = {
55
+ name: class_name,
56
+ fields: [fields.size - 4, 0].max,
57
+ }
58
+
59
+ # Include description if present (compact)
60
+ info[:desc] = schema["description"] if schema["description"]
61
+
62
+ # Include agent methods count if any
63
+ info[:methods] = agent_methods.size if agent_methods.any?
64
+
65
+ if class_name.start_with?("_")
66
+ built_in << info
67
+ else
68
+ custom << info
69
+ end
70
+ end
71
+
72
+ {
73
+ total: schemas.size,
74
+ note: "Use get_schema(class_name) for detailed field info",
75
+ built_in: built_in,
76
+ custom: custom,
77
+ }
78
+ end
79
+
80
+ # Format a single schema for detailed display
81
+ #
82
+ # @param schema [Hash] schema object from Parse (enriched with metadata)
83
+ # @return [Hash] formatted schema details
84
+ def format_schema(schema)
85
+ class_name = schema["className"]
86
+ fields = schema["fields"] || {}
87
+ indexes = schema["indexes"] || {}
88
+ clp = schema["classLevelPermissions"] || {}
89
+ agent_methods = schema["agent_methods"] || []
90
+
91
+ result = {
92
+ class_name: class_name,
93
+ type: class_type(class_name),
94
+ }
95
+
96
+ # Include class description if present
97
+ result[:description] = schema["description"] if schema["description"]
98
+
99
+ # Include analytics usage hint if present (separate from description)
100
+ result[:usage] = schema["usage"] if schema["usage"]
101
+
102
+ result[:fields] = format_fields_detailed(fields)
103
+ result[:indexes] = format_indexes(indexes)
104
+ result[:permissions] = format_clp(clp)
105
+
106
+ # Include agent methods if any
107
+ result[:agent_methods] = agent_methods if agent_methods.any?
108
+
109
+ # Include the canonical "valid state" filter when declared. Lets
110
+ # callers that opt out of the default `apply_canonical_filter`
111
+ # behavior reproduce the predicate manually in their where:.
112
+ if schema["canonical_filter"].is_a?(Hash) && schema["canonical_filter"].any?
113
+ result[:canonical_filter] = schema["canonical_filter"]
114
+ end
115
+
116
+ # Echo the wire-format agent_fields allowlist when declared. The
117
+ # allowlist already filters `result[:fields]` by omission, but the
118
+ # explicit list answers "what may I write in `keys:` for this
119
+ # class" without forcing the consumer to scan the fields array.
120
+ # Storage-form columns (`_p_*`) and other Parse-internal
121
+ # underscored columns are never addressable through agent tools.
122
+ if schema["agent_fields"].is_a?(Array) && schema["agent_fields"].any?
123
+ result[:agent_fields] = schema["agent_fields"]
124
+ end
125
+
126
+ # Echo the narrower join projection (wire-format) when declared.
127
+ # Tells consumers "when this class is included on another class's
128
+ # query, these are the fields you'll see."
129
+ if schema["agent_join_fields"].is_a?(Array) && schema["agent_join_fields"].any?
130
+ result[:agent_join_fields] = schema["agent_join_fields"]
131
+ end
132
+
133
+ # Include relationship edges if any (set by MetadataRegistry)
134
+ if schema["relations"].is_a?(Hash) &&
135
+ (schema["relations"]["outgoing"].to_a.any? || schema["relations"]["incoming"].to_a.any?)
136
+ result[:relations] = schema["relations"]
137
+ end
138
+
139
+ result
140
+ end
141
+
142
+ # Format query results
143
+ #
144
+ # @param class_name [String] the class that was queried
145
+ # @param results [Array<Hash>] array of result objects
146
+ # @param limit [Integer] the limit that was requested
147
+ # @param skip [Integer] the skip offset
148
+ # @param where [Hash, nil] query constraints from the original call
149
+ # @param keys [Array<String>, nil] field projection from the original call
150
+ # @param order [String, nil] sort field from the original call
151
+ # @param include [Array<String>, nil] pointer includes from the original call
152
+ # @return [Hash] formatted results
153
+ def format_query_results(class_name, results, limit:, skip:,
154
+ where: nil, keys: nil, order: nil, include: nil,
155
+ truncated_include_fields: nil)
156
+ total = results.size
157
+ truncated = total > MAX_RESULTS_DISPLAY
158
+ has_more = total >= limit
159
+
160
+ displayed_results = if truncated
161
+ results.first(MAX_RESULTS_DISPLAY)
162
+ else
163
+ results
164
+ end
165
+
166
+ next_call = if has_more
167
+ next_args = {
168
+ class_name: class_name,
169
+ limit: limit,
170
+ skip: skip + limit,
171
+ where: where,
172
+ keys: keys,
173
+ order: order,
174
+ include: include,
175
+ }.compact
176
+ { tool: "query_class", arguments: next_args }
177
+ end
178
+
179
+ # Surface keys-on-include auto-projection metadata so the LLM
180
+ # can see which joins were narrowed and re-ask with explicit
181
+ # dotted paths (`keys: ["user.iconImage"]`) if it needs fields
182
+ # that were dropped. Suppress the key when nothing was auto-
183
+ # projected — keeps the envelope minimal for the common case.
184
+ truncated_includes_payload =
185
+ if truncated_include_fields && !truncated_include_fields.empty?
186
+ truncated_include_fields.transform_values { |meta| meta[:dropped] }.compact
187
+ end
188
+
189
+ {
190
+ class_name: class_name,
191
+ result_count: total,
192
+ pagination: {
193
+ limit: limit,
194
+ skip: skip,
195
+ has_more: has_more,
196
+ },
197
+ truncated: truncated,
198
+ truncated_note: truncated ? "Showing first #{MAX_RESULTS_DISPLAY} of #{total} results" : nil,
199
+ truncated_include_fields: truncated_includes_payload,
200
+ next_call: next_call,
201
+ results: displayed_results.map { |obj| simplify_object(obj) },
202
+ }.compact
203
+ end
204
+
205
+ # Format a single object
206
+ #
207
+ # @param class_name [String] the class name
208
+ # @param object [Hash] the object data
209
+ # @param truncated_include_fields [Hash, nil] map of pointer-name => {dropped:, source:}
210
+ # when keys-on-include auto-projection narrowed any joined record.
211
+ # @return [Hash] formatted object
212
+ def format_object(class_name, object, truncated_include_fields: nil)
213
+ envelope = {
214
+ class_name: class_name,
215
+ object_id: object["objectId"],
216
+ created_at: object["createdAt"],
217
+ updated_at: object["updatedAt"],
218
+ object: simplify_object(object),
219
+ }
220
+ if truncated_include_fields && !truncated_include_fields.empty?
221
+ envelope[:truncated_include_fields] =
222
+ truncated_include_fields.transform_values { |meta| meta[:dropped] }
223
+ end
224
+ envelope
225
+ end
226
+
227
+ private
228
+
229
+ # Determine the type of class (built-in vs custom)
230
+ def class_type(class_name)
231
+ case class_name
232
+ when "_User" then "built-in: User accounts"
233
+ when "_Role" then "built-in: Access roles"
234
+ when "_Session" then "built-in: User sessions"
235
+ when "_Installation" then "built-in: Device installations"
236
+ when "_Product" then "built-in: In-app purchases"
237
+ when "_Audience" then "built-in: Push audiences"
238
+ else "custom"
239
+ end
240
+ end
241
+
242
+ # Format field list for summary view
243
+ def format_field_list(fields)
244
+ # Exclude default Parse fields for cleaner output
245
+ default_fields = %w[objectId createdAt updatedAt ACL]
246
+
247
+ fields.reject { |name, _| default_fields.include?(name) }
248
+ .map { |name, config| "#{name} (#{type_name(config)})" }
249
+ end
250
+
251
+ # Format fields with full details
252
+ def format_fields_detailed(fields)
253
+ fields.map do |name, config|
254
+ # Handle both Hash configs and simple type strings
255
+ config = { "type" => config.to_s } unless config.is_a?(Hash)
256
+
257
+ field_info = {
258
+ name: name,
259
+ type: type_name(config),
260
+ required: config["required"] || false,
261
+ }
262
+
263
+ # Add field description if present (from agent metadata)
264
+ if config["description"]
265
+ field_info[:description] = config["description"]
266
+ end
267
+
268
+ # Per-value enum documentation declared via `property … _enum:`.
269
+ # Surfaced as a list of {value, description} objects so an LLM
270
+ # composing a `where:` constraint can pick the right value
271
+ # without re-querying or guessing from the bare value names.
272
+ if config["allowed_values"].is_a?(Array) && config["allowed_values"].any?
273
+ field_info[:allowed_values] = config["allowed_values"]
274
+ end
275
+
276
+ # Surface the agent_large_fields annotation so an LLM client can
277
+ # project this field away in its first query rather than hitting
278
+ # the dispatcher's response-size cap.
279
+ if config["large_field"]
280
+ field_info[:large_field] = true
281
+ end
282
+
283
+ # Add pointer/relation target class if applicable. Suppress
284
+ # `target_class` when the target is a hidden class — and for
285
+ # Pointer fields, collapse `query_hint` to the generic
286
+ # `<targetClass>` placeholder. Resolve via MetadataRegistry
287
+ # when available; pass through when the registry is unloaded
288
+ # (pure-unit contexts).
289
+ if config["type"] == "Pointer"
290
+ target = config["targetClass"]
291
+ if target_class_hidden?(target)
292
+ field_info[:query_hint] = pointer_query_hint(name, nil)
293
+ else
294
+ field_info[:target_class] = target
295
+ field_info[:query_hint] = pointer_query_hint(name, target)
296
+ end
297
+ elsif config["type"] == "Relation"
298
+ target = config["targetClass"]
299
+ field_info[:target_class] = target unless target_class_hidden?(target)
300
+ end
301
+
302
+ # Add default value if present
303
+ if config.key?("defaultValue")
304
+ field_info[:default] = config["defaultValue"]
305
+ end
306
+
307
+ field_info
308
+ end
309
+ end
310
+
311
+ # Format indexes for display
312
+ def format_indexes(indexes)
313
+ indexes.map do |name, definition|
314
+ {
315
+ name: name,
316
+ fields: definition.keys,
317
+ unique: name.include?("unique") || definition.values.include?("unique"),
318
+ }
319
+ end
320
+ end
321
+
322
+ # Format class-level permissions
323
+ def format_clp(clp)
324
+ return {} if clp.empty?
325
+
326
+ clp.transform_values do |permission|
327
+ case permission
328
+ when Hash
329
+ permission.keys.map do |key|
330
+ case key
331
+ when "*" then "public"
332
+ when /^role:/ then key
333
+ else "user:#{key}"
334
+ end
335
+ end
336
+ when true then ["public"]
337
+ when false then ["none"]
338
+ else [permission.to_s]
339
+ end
340
+ end
341
+ end
342
+
343
+ # Get human-readable type name
344
+ def type_name(config)
345
+ type = config["type"]
346
+ base_name = TYPE_NAMES[type] || type.to_s.downcase
347
+
348
+ case type
349
+ when "Pointer"
350
+ "#{base_name} → #{config["targetClass"]}"
351
+ when "Relation"
352
+ "#{base_name} → #{config["targetClass"]}"
353
+ else
354
+ base_name
355
+ end
356
+ end
357
+
358
+ # True when MetadataRegistry positively reports `target` as
359
+ # hidden. Falsy when the registry is unloaded (pure-unit
360
+ # contexts) — preserves the historical "show the target" behavior
361
+ # for callers that don't load the agent layer.
362
+ def target_class_hidden?(target)
363
+ target &&
364
+ defined?(Parse::Agent::MetadataRegistry) &&
365
+ Parse::Agent::MetadataRegistry.respond_to?(:hidden?) &&
366
+ Parse::Agent::MetadataRegistry.hidden?(target)
367
+ end
368
+
369
+ # Build a one-line value-shape hint for a pointer field. Surfaced
370
+ # in get_schema output so an LLM composing a where: constraint
371
+ # against a pointer column knows the accepted shapes without
372
+ # having to query a sample row first. Mirrors the shapes the
373
+ # SDK actually accepts in convert_constraints_for_aggregation
374
+ # (mongo-direct) and the REST find_objects path.
375
+ def pointer_query_hint(field_name, target_class)
376
+ target = target_class || "<targetClass>"
377
+ equality = "{ #{field_name.inspect} => \"<objectId>\" } or " \
378
+ "{ #{field_name.inspect} => { \"__type\" => \"Pointer\", " \
379
+ "\"className\" => #{target.inspect}, \"objectId\" => \"<id>\" } }"
380
+ in_shape = "{ #{field_name.inspect} => { \"$in\" => [\"<id1>\", \"<id2>\"] } } " \
381
+ "(bare objectIds; the SDK normalizes against the pointer storage shape)"
382
+ "Pointer to #{target}. Equality: #{equality}. $in/$nin: #{in_shape}."
383
+ end
384
+
385
+ # Simplify an object for display (resolve __type fields)
386
+ def simplify_object(obj)
387
+ return obj unless obj.is_a?(Hash)
388
+
389
+ obj.transform_values do |value|
390
+ simplify_value(value)
391
+ end
392
+ end
393
+
394
+ # Simplify a single value
395
+ def simplify_value(value)
396
+ case value
397
+ when Hash
398
+ simplify_typed_value(value)
399
+ when Array
400
+ value.map { |v| simplify_value(v) }
401
+ else
402
+ value
403
+ end
404
+ end
405
+
406
+ # Simplify Parse typed values (__type fields)
407
+ def simplify_typed_value(hash)
408
+ type = hash["__type"]
409
+
410
+ case type
411
+ when "Date"
412
+ hash["iso"]
413
+ when "Pointer"
414
+ {
415
+ _type: "Pointer",
416
+ class: hash["className"],
417
+ id: hash["objectId"],
418
+ }
419
+ when "File"
420
+ {
421
+ _type: "File",
422
+ name: hash["name"],
423
+ url: hash["url"],
424
+ }
425
+ when "GeoPoint"
426
+ {
427
+ _type: "GeoPoint",
428
+ latitude: hash["latitude"],
429
+ longitude: hash["longitude"],
430
+ }
431
+ when "Polygon"
432
+ {
433
+ _type: "Polygon",
434
+ coordinates: hash["coordinates"],
435
+ }
436
+ when "Bytes"
437
+ {
438
+ _type: "Bytes",
439
+ base64: hash["base64"]&.slice(0, 50)&.then { |s| "#{s}..." },
440
+ }
441
+ when "Relation"
442
+ {
443
+ _type: "Relation",
444
+ class: hash["className"],
445
+ }
446
+ else
447
+ # Regular object or unknown type - recurse
448
+ simplify_object(hash)
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end