parse-stack-next 5.7.6 → 5.8.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 +4 -4
- data/CHANGELOG.md +830 -0
- data/README.md +14 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +317 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +93 -1
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +21 -3
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +72 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
data/lib/parse/mongodb.rb
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
require "date"
|
|
5
5
|
require "set"
|
|
6
6
|
require "time"
|
|
7
|
+
require "erb"
|
|
7
8
|
require_relative "pipeline_security"
|
|
8
9
|
require_relative "clp_scope"
|
|
9
10
|
require_relative "acl_scope"
|
|
@@ -1165,6 +1166,9 @@ module Parse
|
|
|
1165
1166
|
# in {Parse::PipelineSecurity::DENIED_OPERATORS}.
|
|
1166
1167
|
DENIED_OPERATORS = Parse::PipelineSecurity::DENIED_OPERATORS
|
|
1167
1168
|
|
|
1169
|
+
# @!visibility private
|
|
1170
|
+
EMPTY_COLUMN_SET = Set.new.freeze
|
|
1171
|
+
|
|
1168
1172
|
# @!visibility private
|
|
1169
1173
|
# Default BFS depth for role-graph expansion. Real-world role graphs
|
|
1170
1174
|
# are 2-4 deep; 10 leaves headroom for unusual hierarchies without
|
|
@@ -1803,6 +1807,12 @@ module Parse
|
|
|
1803
1807
|
pipeline, class_name: collection_name, enabled: rewrite_lookups,
|
|
1804
1808
|
)
|
|
1805
1809
|
|
|
1810
|
+
# Decide, from the caller's own stages, whether a returned row's
|
|
1811
|
+
# `_id` is still the stored one. The `_User` self exemption below
|
|
1812
|
+
# trusts the output `_id`, so it is granted only when no caller
|
|
1813
|
+
# stage could have rewritten it; otherwise the field is stripped.
|
|
1814
|
+
identity_preserved = Parse::PipelineSecurity.identity_preserving?(pipeline)
|
|
1815
|
+
|
|
1806
1816
|
# Three-layer ACL simulation on the mongo-direct path:
|
|
1807
1817
|
#
|
|
1808
1818
|
# 1. Top-level $match: filter the queried collection's rows by
|
|
@@ -1824,42 +1834,38 @@ module Parse
|
|
|
1824
1834
|
# pipeline above; the injected stages reference `_rperm` but
|
|
1825
1835
|
# are SDK-generated (not attacker-controlled), so no
|
|
1826
1836
|
# re-validation is needed before they're handed to MongoDB.
|
|
1827
|
-
if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
|
|
1828
|
-
pipeline = prepend_or_fold_acl_match(pipeline, acl_stage)
|
|
1829
|
-
end
|
|
1830
|
-
pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)
|
|
1831
|
-
|
|
1832
1837
|
# Class-Level Permissions boundary check. Parse Server's REST
|
|
1833
1838
|
# aggregate endpoint runs master-key-only and does NOT enforce
|
|
1834
1839
|
# CLP; the mongo-direct path bypasses Parse Server entirely so
|
|
1835
|
-
# the SDK is the only enforcement layer.
|
|
1836
|
-
#
|
|
1837
|
-
#
|
|
1840
|
+
# the SDK is the only enforcement layer. Evaluate the `find` CLP
|
|
1841
|
+
# with Parse Server's branch semantics: a public / user / role
|
|
1842
|
+
# grant permits every row, otherwise a pointerFields or
|
|
1843
|
+
# readUserFields grant permits only the rows whose named pointer
|
|
1844
|
+
# references the requesting user. Raises when the scope cannot
|
|
1845
|
+
# find at all. Master-key (resolution.master?) bypasses.
|
|
1838
1846
|
perms_for_clp = resolution&.permission_strings
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
collection_name, :find,
|
|
1843
|
-
"CLP refuses find on '#{collection_name}' for the current scope.",
|
|
1844
|
-
)
|
|
1845
|
-
end
|
|
1846
|
-
end
|
|
1847
|
+
pointer_fields = Parse::CLPScope.row_constraint_for!(
|
|
1848
|
+
collection_name, :find, resolution, client: clp_client_for(resolution),
|
|
1849
|
+
)
|
|
1847
1850
|
|
|
1848
|
-
#
|
|
1849
|
-
#
|
|
1850
|
-
#
|
|
1851
|
-
#
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
)
|
|
1861
|
-
end
|
|
1851
|
+
# The pointer-permission constraint is applied IN the pipeline,
|
|
1852
|
+
# folded into the leading ACL `$match`, so it runs before any
|
|
1853
|
+
# caller `$skip` / `$limit` / `$count` / `$group`. Filtering the
|
|
1854
|
+
# fetched rows afterwards made `count_direct` return 0 (the
|
|
1855
|
+
# `$count` row has no pointer column) and returned short or
|
|
1856
|
+
# empty pages.
|
|
1857
|
+
front_predicates = []
|
|
1858
|
+
if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
|
|
1859
|
+
front_predicates << (acl_stage["$match"] || acl_stage[:$match])
|
|
1860
|
+
end
|
|
1861
|
+
if pointer_fields
|
|
1862
|
+
front_predicates << Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id)
|
|
1862
1863
|
end
|
|
1864
|
+
unless front_predicates.empty?
|
|
1865
|
+
front = front_predicates.size == 1 ? front_predicates.first : { "$and" => front_predicates }
|
|
1866
|
+
pipeline = prepend_or_fold_acl_match(pipeline, { "$match" => front })
|
|
1867
|
+
end
|
|
1868
|
+
pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution, class_name: collection_name)
|
|
1863
1869
|
|
|
1864
1870
|
agg_opts = {}
|
|
1865
1871
|
agg_opts[:max_time_ms] = max_time_ms if max_time_ms
|
|
@@ -1879,25 +1885,25 @@ module Parse
|
|
|
1879
1885
|
results = with_query_killed_retry(collection_name) { coll.aggregate(pipeline, agg_opts).to_a }
|
|
1880
1886
|
Parse::ACLScope.redact_results!(results, resolution)
|
|
1881
1887
|
|
|
1882
|
-
#
|
|
1883
|
-
#
|
|
1884
|
-
#
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
#
|
|
1892
|
-
# session's claim composition and walk-delete from every
|
|
1893
|
-
# row + embedded sub-document. Top-level $project would also
|
|
1894
|
-
# work but doesn't reach inside `$lookup`-included sub-docs,
|
|
1895
|
-
# so the post-walker is the defense-in-depth layer.
|
|
1888
|
+
# Protected fields stripping, top-level keys only (Parse Server
|
|
1889
|
+
# deletes `object[key]` and does not descend). The queried class's
|
|
1890
|
+
# set applies to each row; every `$lookup` in the executed pipeline
|
|
1891
|
+
# pulls rows of another class under its `as` key, and those rows get
|
|
1892
|
+
# THAT class's set, the same as Parse Server's include sub-query
|
|
1893
|
+
# does. `_User` rows that are the requesting user keep their own
|
|
1894
|
+
# protected fields, as on REST, but only when that identity is
|
|
1895
|
+
# proven: the caller's stages preserve `_id` (root rows), or the
|
|
1896
|
+
# join's head strip already decided ownership on the stored `_id`
|
|
1897
|
+
# (joined rows, see {Parse::ACLScope.rewrite_pipeline}).
|
|
1896
1898
|
unless resolution.nil? || resolution.master?
|
|
1897
1899
|
strip_set = Parse::CLPScope.protected_fields_for(
|
|
1898
|
-
collection_name, perms_for_clp,
|
|
1900
|
+
collection_name, perms_for_clp, client: clp_client_for(resolution),
|
|
1899
1901
|
)
|
|
1900
|
-
Parse::CLPScope.redact_protected_fields!(
|
|
1902
|
+
Parse::CLPScope.redact_protected_fields!(
|
|
1903
|
+
results, strip_set, class_name: collection_name,
|
|
1904
|
+
user_id: identity_preserved ? resolution.user_id : nil,
|
|
1905
|
+
) if strip_set.any?
|
|
1906
|
+
redact_joined_protected_fields!(results, pipeline, resolution)
|
|
1901
1907
|
|
|
1902
1908
|
# Process-level floor: recursively strip Parse-internal credential
|
|
1903
1909
|
# columns (_hashed_password, _session_token, _auth_data_*, _rperm,
|
|
@@ -1920,6 +1926,56 @@ module Parse
|
|
|
1920
1926
|
raise
|
|
1921
1927
|
end
|
|
1922
1928
|
|
|
1929
|
+
# The client whose schema decides CLP for a resolution. A user-scoped
|
|
1930
|
+
# client (from `become` / `session_client`) holds no master key and
|
|
1931
|
+
# cannot read `/schemas`, so it falls back to the default client (nil
|
|
1932
|
+
# here), which is what every CLP lookup used before resolutions
|
|
1933
|
+
# carried a client.
|
|
1934
|
+
# @!visibility private
|
|
1935
|
+
def clp_client_for(resolution)
|
|
1936
|
+
c = Parse::ACLScope.client_of(resolution)
|
|
1937
|
+
return nil if c.nil?
|
|
1938
|
+
return nil unless c.respond_to?(:master_key) && !c.master_key.to_s.empty?
|
|
1939
|
+
c
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1942
|
+
# Strip each joined class's protectedFields from the rows a top-level
|
|
1943
|
+
# `$lookup` pulled in. Mirrors Parse Server's include, which fetches the
|
|
1944
|
+
# included objects with a sub-query on their own class and therefore
|
|
1945
|
+
# applies that class's protectedFields (plus the server default
|
|
1946
|
+
# `_User.email` protection) to them.
|
|
1947
|
+
# @!visibility private
|
|
1948
|
+
def redact_joined_protected_fields!(results, pipeline, resolution)
|
|
1949
|
+
return if results.nil? || results.empty? || !pipeline.is_a?(Array)
|
|
1950
|
+
perms = resolution.permission_strings
|
|
1951
|
+
return if perms.nil?
|
|
1952
|
+
# The self exemption is safe only when the join's sub-pipeline began
|
|
1953
|
+
# with the strip stage, which decided ownership on the stored `_id`.
|
|
1954
|
+
self_id = Parse::ACLScope.rewrites_joins?(resolution) ? resolution.user_id : nil
|
|
1955
|
+
pipeline.each do |stage|
|
|
1956
|
+
next unless stage.is_a?(Hash)
|
|
1957
|
+
spec = stage["$lookup"] || stage[:$lookup]
|
|
1958
|
+
next unless spec.is_a?(Hash)
|
|
1959
|
+
from = spec["from"] || spec[:from]
|
|
1960
|
+
as = spec["as"] || spec[:as]
|
|
1961
|
+
next if from.to_s.empty? || as.to_s.empty?
|
|
1962
|
+
set = Parse::CLPScope.protected_fields_for(from.to_s, perms, client: clp_client_for(resolution))
|
|
1963
|
+
next if set.empty?
|
|
1964
|
+
path = as.to_s.split(".")
|
|
1965
|
+
results.each do |row|
|
|
1966
|
+
value = path.reduce(row) { |node, seg| node.is_a?(Hash) ? (node[seg] || node[seg.to_sym]) : nil }
|
|
1967
|
+
docs = case value
|
|
1968
|
+
when Hash then [value]
|
|
1969
|
+
when Array then value.select { |v| v.is_a?(Hash) }
|
|
1970
|
+
else []
|
|
1971
|
+
end
|
|
1972
|
+
next if docs.empty?
|
|
1973
|
+
Parse::CLPScope.redact_protected_fields!(docs, set, class_name: from.to_s, user_id: self_id)
|
|
1974
|
+
end
|
|
1975
|
+
end
|
|
1976
|
+
nil
|
|
1977
|
+
end
|
|
1978
|
+
|
|
1923
1979
|
# Inject the scoped ACL `$match` at the front of a pipeline — UNLESS
|
|
1924
1980
|
# the first stage is `$geoNear`, which MongoDB requires to be
|
|
1925
1981
|
# pipeline stage 0. In that case fold the ACL predicate into
|
|
@@ -2279,6 +2335,8 @@ module Parse
|
|
|
2279
2335
|
return nil unless doc.is_a?(Hash)
|
|
2280
2336
|
|
|
2281
2337
|
result = {}
|
|
2338
|
+
included = {}
|
|
2339
|
+
file_fields = file_columns_for(class_name)
|
|
2282
2340
|
|
|
2283
2341
|
doc.each do |key, value|
|
|
2284
2342
|
key_str = key.to_s
|
|
@@ -2306,18 +2364,10 @@ module Parse
|
|
|
2306
2364
|
# Convert MongoDB ACL format (r/w) to Parse format (read/write)
|
|
2307
2365
|
result["ACL"] = convert_acl_to_parse(value)
|
|
2308
2366
|
when /^_included_(.+)$/
|
|
2309
|
-
# Included/resolved pointer field from $lookup
|
|
2310
|
-
#
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
# Recursively convert the embedded document to Parse format
|
|
2314
|
-
result[field_name] = convert_document_to_parse(value)
|
|
2315
|
-
elsif value.nil?
|
|
2316
|
-
# Preserve nil for unresolved optional relationships
|
|
2317
|
-
result[field_name] = nil
|
|
2318
|
-
else
|
|
2319
|
-
result[field_name] = value
|
|
2320
|
-
end
|
|
2367
|
+
# Included/resolved pointer field from $lookup. Resolved after
|
|
2368
|
+
# the loop, once the sibling `_p_<field>` pointer (which names
|
|
2369
|
+
# the included object's class) has been seen.
|
|
2370
|
+
included[$1] = value
|
|
2321
2371
|
when /^_include_id_/
|
|
2322
2372
|
# Skip temporary lookup ID fields (used internally for $lookup)
|
|
2323
2373
|
next
|
|
@@ -2330,8 +2380,33 @@ module Parse
|
|
|
2330
2380
|
# Skip other internal fields starting with underscore
|
|
2331
2381
|
next
|
|
2332
2382
|
else
|
|
2333
|
-
# Regular fields - recursively convert nested documents
|
|
2334
|
-
|
|
2383
|
+
# Regular fields - recursively convert nested documents. Parse
|
|
2384
|
+
# Server stores a File column as the bare file name; REST returns
|
|
2385
|
+
# it as a `File` object, so rebuild that shape.
|
|
2386
|
+
result[key_str] = if value.is_a?(String) && file_fields.include?(key_str)
|
|
2387
|
+
file_to_parse(value)
|
|
2388
|
+
else
|
|
2389
|
+
convert_value_to_parse(value)
|
|
2390
|
+
end
|
|
2391
|
+
end
|
|
2392
|
+
end
|
|
2393
|
+
|
|
2394
|
+
included.each do |field_name, value|
|
|
2395
|
+
if value.is_a?(Hash)
|
|
2396
|
+
# REST returns an included object as a full `Object` carrying
|
|
2397
|
+
# its `__type` and `className`. The class comes from the stored
|
|
2398
|
+
# pointer (`Class$objectId`); without it the object decoded as nil.
|
|
2399
|
+
pointer = doc["_p_#{field_name}"] || doc[:"_p_#{field_name}"]
|
|
2400
|
+
target = pointer.is_a?(String) && pointer.include?("$") ? pointer.split("$", 2).first : nil
|
|
2401
|
+
converted = convert_document_to_parse(value, target)
|
|
2402
|
+
converted["__type"] = Parse::Model::TYPE_OBJECT if target
|
|
2403
|
+
result[field_name] = converted
|
|
2404
|
+
elsif value.nil?
|
|
2405
|
+
# The include resolved to nothing: a dangling pointer, or a row
|
|
2406
|
+
# the scope cannot read. REST omits the field in that case.
|
|
2407
|
+
result.delete(field_name)
|
|
2408
|
+
else
|
|
2409
|
+
result[field_name] = value
|
|
2335
2410
|
end
|
|
2336
2411
|
end
|
|
2337
2412
|
|
|
@@ -2341,6 +2416,41 @@ module Parse
|
|
|
2341
2416
|
result
|
|
2342
2417
|
end
|
|
2343
2418
|
|
|
2419
|
+
# Mongo column names of the `:file` properties declared on the model
|
|
2420
|
+
# registered for `class_name`.
|
|
2421
|
+
# @return [Set<String>]
|
|
2422
|
+
# @!visibility private
|
|
2423
|
+
def file_columns_for(class_name)
|
|
2424
|
+
return EMPTY_COLUMN_SET if class_name.nil?
|
|
2425
|
+
klass = Parse::Model.find_class(class_name.to_s) rescue nil
|
|
2426
|
+
return EMPTY_COLUMN_SET unless klass.respond_to?(:fields)
|
|
2427
|
+
map = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
2428
|
+
klass.fields.each_with_object(Set.new) do |(local, type), set|
|
|
2429
|
+
next unless type == :file
|
|
2430
|
+
set << (map[local] || local).to_s
|
|
2431
|
+
end
|
|
2432
|
+
rescue StandardError
|
|
2433
|
+
EMPTY_COLUMN_SET
|
|
2434
|
+
end
|
|
2435
|
+
|
|
2436
|
+
# Parse Server's REST shape for a stored file name. The URL follows
|
|
2437
|
+
# Parse Server's default files route (`<serverURL>/files/<appId>/<name>`),
|
|
2438
|
+
# used by the GridFS and filesystem adapters and by adapters without
|
|
2439
|
+
# direct access. An adapter with direct access (for example S3 with
|
|
2440
|
+
# `directAccess: true`) serves a different URL; there REST is the
|
|
2441
|
+
# authoritative source.
|
|
2442
|
+
# @!visibility private
|
|
2443
|
+
def file_to_parse(name)
|
|
2444
|
+
out = { "__type" => Parse::Model::TYPE_FILE, "name" => name }
|
|
2445
|
+
client = Parse::Client.client? ? (Parse::Client.client rescue nil) : nil
|
|
2446
|
+
if client && client.respond_to?(:server_url) && client.respond_to?(:application_id) &&
|
|
2447
|
+
!client.server_url.to_s.empty? && !client.application_id.to_s.empty?
|
|
2448
|
+
base = client.server_url.to_s.chomp("/")
|
|
2449
|
+
out["url"] = "#{base}/files/#{client.application_id}/#{ERB::Util.url_encode(name)}"
|
|
2450
|
+
end
|
|
2451
|
+
out
|
|
2452
|
+
end
|
|
2453
|
+
|
|
2344
2454
|
# Convert multiple MongoDB documents to Parse format
|
|
2345
2455
|
# @param docs [Array<Hash>] the MongoDB documents
|
|
2346
2456
|
# @param class_name [String] the Parse class name
|