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.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- 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
|