parse-stack-next 5.8.0 → 5.8.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +318 -0
  3. data/README.md +25 -19
  4. data/docs/caching.md +50 -0
  5. data/docs/mcp_guide.md +15 -3
  6. data/docs/webhooks_guide.md +59 -7
  7. data/lib/parse/acl_scope.rb +71 -9
  8. data/lib/parse/agent/describe.rb +8 -2
  9. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  10. data/lib/parse/agent.rb +205 -20
  11. data/lib/parse/atlas_search/index_manager.rb +5 -1
  12. data/lib/parse/atlas_search.rb +49 -1
  13. data/lib/parse/authorization.rb +235 -3
  14. data/lib/parse/cache/sub_cache.rb +26 -0
  15. data/lib/parse/client/authentication.rb +19 -1
  16. data/lib/parse/client/batch.rb +35 -2
  17. data/lib/parse/client.rb +26 -2
  18. data/lib/parse/clp_scope.rb +37 -3
  19. data/lib/parse/live_query/client.rb +72 -2
  20. data/lib/parse/lock_backend.rb +4 -3
  21. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  22. data/lib/parse/model/associations/has_many.rb +11 -0
  23. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  24. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  25. data/lib/parse/model/classes/role.rb +23 -0
  26. data/lib/parse/model/classes/session.rb +279 -19
  27. data/lib/parse/model/classes/user.rb +33 -12
  28. data/lib/parse/model/core/actions.rb +19 -4
  29. data/lib/parse/model/core/fetching.rb +29 -2
  30. data/lib/parse/model/core/field_guards.rb +16 -8
  31. data/lib/parse/model/object.rb +52 -1
  32. data/lib/parse/model/push.rb +11 -1
  33. data/lib/parse/mongodb.rb +19 -1
  34. data/lib/parse/query/constraint.rb +1 -1
  35. data/lib/parse/query/constraints.rb +31 -27
  36. data/lib/parse/query.rb +793 -55
  37. data/lib/parse/stack/version.rb +1 -1
  38. data/lib/parse/stack.rb +19 -0
  39. data/lib/parse/vector_search/hybrid.rb +74 -15
  40. data/lib/parse/vector_search.rb +214 -7
  41. data/lib/parse/webhooks/payload.rb +43 -0
  42. data/lib/parse/webhooks.rb +312 -9
  43. metadata +1 -1
@@ -501,6 +501,8 @@ module Parse
501
501
  master: payload.master? || false,
502
502
  is_new: payload.original.blank?,
503
503
  )
504
+ # A guard revert is not the handler assigning the ACL.
505
+ pre_obj.reset_webhook_handler_acl_assigned! if pre_obj.respond_to?(:reset_webhook_handler_acl_assigned!)
504
506
  end
505
507
  end
506
508
  end
@@ -529,6 +531,7 @@ module Parse
529
531
  # ran ActiveModel before_save callbacks locally. A client-spoofed
530
532
  # `_RB_` without master falls through and runs them here.
531
533
  unless trusted_ruby_initiated
534
+ adopt_request_user_as_acl_owner!(payload, result)
532
535
  before_save_result = result.run_before_save_callbacks
533
536
  # If a before_save callback halted the chain (returned false), reject the save.
534
537
  if before_save_result == false
@@ -779,18 +782,23 @@ module Parse
779
782
  #
780
783
  # Fields are compared against a fresh build of the payload, so only the
781
784
  # fields the handler actually changed are encoded from the Ruby object.
782
- # Two limits come from what Parse Server sends: an operator on a dotted
783
- # sub-key (`"meta.count"`) reaches the webhook only as its resulting
784
- # sub-document, and is written back that way when the reply is not
785
- # `nil`; and a field the handler rewrites is written as an absolute
786
- # value.
785
+ # A field the handler rewrites is written as an absolute value. An
786
+ # operator on a dotted sub-key (`"meta.count"`) reaches the webhook only
787
+ # as its resulting sub-document, so on an update a changed sub-document
788
+ # is written back as dotted keys for just the sub-keys that differ from
789
+ # the stored one. A concurrent write to another sub-key survives; the
790
+ # changed sub-key itself is written as its resulting value. The split
791
+ # is one level deep (a changed sub-key is written whole at
792
+ # `field.sub`), and a field whose writes would carry a typed value is
793
+ # written whole.
787
794
  #
788
795
  # @param payload [Parse::Webhooks::Payload] the beforeSave payload.
789
796
  # @param obj [Parse::Object, nil] the handler's object (nil when none).
790
797
  # @param overrides [Hash, nil] field values a handler returned as a Hash.
791
798
  # @param include_create_defaults [Boolean] on a create, also write the
792
- # object's dirty fields the client did not send (declared defaults,
793
- # default ACL), matching what an SDK-side create sends.
799
+ # object's dirty fields the client did not send (declared defaults and
800
+ # the ACL the class policy resolves), matching what an SDK-side create
801
+ # sends.
794
802
  # @return [Hash, nil] the reply object, or nil for "unchanged".
795
803
  def before_save_reply(payload, obj, overrides: nil, include_create_defaults: false)
796
804
  return nil unless payload && payload.object?
@@ -806,27 +814,238 @@ module Parse
806
814
  if include_create_defaults && raw_original.nil?
807
815
  client_keys = raw_object.keys.map(&:to_s)
808
816
  dirty = obj.changed.map(&:to_sym) & snapshot_fields(obj)
817
+ # This includes the ACL. A create with no `ACL` key is stored by
818
+ # Parse Server as public read and write, so a class with an ACL
819
+ # policy always replies with the ACL that policy resolved (owned
820
+ # by the requesting user where the policy names an owner; see
821
+ # {adopt_request_user_as_acl_owner!}) or the ACL the handler set.
822
+ # An ACL the client sent is kept as sent.
809
823
  wire_values(obj, dirty).each do |remote, value|
810
824
  next if client_keys.include?(remote) || changes.key?(remote)
811
825
  changes[remote] = value
812
826
  end
813
827
  end
828
+ # An ACL the handler assigned on a create is written even when it
829
+ # equals the default stamp or the client's ACL, whatever the handler
830
+ # returned (the object, `true`, `nil`, or a Hash, whose own `ACL`
831
+ # still wins below). Diffing alone misses `obj.acl = Parse::ACL.new`
832
+ # under a `{}` default, and a create with no `ACL` key is stored
833
+ # public read and write.
834
+ if handler_assigned_acl?(obj) && !changes.key?("ACL")
835
+ changes.merge!(wire_values(obj, [:acl]).slice("ACL"))
836
+ end
814
837
  end
815
838
  overrides = overrides.as_json if overrides.is_a?(Hash)
816
839
  return nil if changes.empty? && drops.empty? && overrides.blank?
817
840
 
818
841
  reply = client_write_data(raw_object, raw_original)
842
+ overrides = overrides.present? ? remote_override_keys(payload, overrides) : {}
843
+ # A field the handler wrote or dropped replaces the client's dotted
844
+ # sub-key writes for it; MongoDB refuses `meta` and `meta.x` together.
845
+ (drops + changes.keys + overrides.keys).each do |remote|
846
+ reply.delete_if { |key, _| key.start_with?("#{remote}.") }
847
+ end
819
848
  drops.each { |remote| reply.delete(remote) }
820
849
  reply.merge!(changes)
821
- reply.merge!(overrides.transform_keys(&:to_s)) if overrides.present?
850
+ reply.merge!(overrides)
851
+ fold_dotted_overrides!(reply, overrides.keys, raw_object: raw_object, create: raw_original.nil?)
852
+ reply
853
+ end
854
+
855
+ # A handler's Hash override keyed by Ruby names (`{acl: ...}`,
856
+ # `{author_name: ...}`) is mapped to the remote field names the reply
857
+ # uses, so it replaces the matching field instead of sitting beside it.
858
+ # A dotted key maps its first segment.
859
+ # @!visibility private
860
+ def remote_override_keys(payload, overrides)
861
+ klass = begin
862
+ name = payload.parse_class
863
+ name.present? ? Parse::Object.find_class(name) : nil
864
+ rescue StandardError
865
+ nil
866
+ end
867
+ map = klass.respond_to?(:field_map) ? klass.field_map : {}
868
+ overrides.each_with_object({}) do |(key, value), out|
869
+ key = key.to_s
870
+ head, rest = key.split(".", 2)
871
+ remote = map[head.to_sym]
872
+ head = remote.to_s if remote
873
+ out[rest ? "#{head}.#{rest}" : head] = value
874
+ end
875
+ end
876
+
877
+ # A handler's Hash override may name a sub-key (`"meta.y"`) of a field
878
+ # the reply writes whole (a create, or a field written whole on an
879
+ # update). MongoDB refuses `meta` and `meta.y` in one update, so the
880
+ # sub-key write is folded into the whole value. An override deeper
881
+ # than one level (`"meta.count.value"`) is folded into a `field.sub`
882
+ # write seeded from the pending object, because Parse Server rebuilds
883
+ # the afterSave object from a dotted key only one level deep. On a
884
+ # create every dotted override folds into its whole field.
885
+ #
886
+ # When the value it folds into is not a plain sub-document, a client's
887
+ # value is replaced by the handler's sub-key, and two of the handler's
888
+ # own overrides that conflict (`"meta.a" => 5` with `"meta.a.b" => 6`)
889
+ # raise {ResponseError}.
890
+ # @!visibility private
891
+ def fold_dotted_overrides!(reply, override_keys, raw_object: {}, create: false)
892
+ handler_keys = override_keys.to_set
893
+ override_keys.sort_by { |k| k.count(".") }.each do |key|
894
+ next unless key.include?(".") && reply.key?(key)
895
+ segments = key.split(".")
896
+ # The nearest ancestor path the reply writes whole, if any.
897
+ parent = (1...segments.length).map { |n| segments.first(n).join(".") }.reverse.find { |p| reply.key?(p) }
898
+ unless parent
899
+ next if !create && segments.length <= 2
900
+ parent = create ? segments.first : segments.first(2).join(".")
901
+ seed = parent.split(".").reduce(raw_object) { |cur, seg| cur.is_a?(Hash) ? cur[seg] : nil }
902
+ reply[parent] = plain_sub_document?(seed) ? seed.deep_dup : {}
903
+ end
904
+ value = reply.delete(key)
905
+ whole = reply[parent]
906
+ if plain_sub_document?(whole)
907
+ whole = whole.deep_dup
908
+ elsif handler_keys.include?(parent)
909
+ raise Parse::Webhooks::ResponseError,
910
+ "before_save reply: #{key} conflicts with #{parent} in the handler's reply"
911
+ else
912
+ whole = {}
913
+ end
914
+ *dirs, leaf = segments.drop(parent.count(".") + 1)
915
+ node = dirs.reduce(whole) do |cur, dir|
916
+ cur[dir] = {} unless plain_sub_document?(cur[dir])
917
+ cur[dir]
918
+ end
919
+ if value.is_a?(Hash) && value.key?("__op")
920
+ # A whole write stores its value as data, so an operator folded
921
+ # into it is applied here rather than written as a Hash.
922
+ result = fold_override_op(key, node[leaf], value)
923
+ if result.equal?(FOLD_DELETE)
924
+ node.delete(leaf)
925
+ else
926
+ node[leaf] = result
927
+ end
928
+ else
929
+ node[leaf] = value
930
+ end
931
+ reply[parent] = whole
932
+ end
822
933
  reply
823
934
  end
824
935
 
936
+ # @!visibility private
937
+ FOLD_DELETE = Object.new.freeze
938
+
939
+ # Apply a handler's sub-key operator to the value it replaces inside a
940
+ # sub-document the reply writes whole, with Parse Server's semantics
941
+ # for that operator. Raises {ResponseError} (an `{error}` reply, so
942
+ # Parse Server refuses the save) for an operator that cannot be applied
943
+ # to a plain value, rather than storing the operator Hash as data.
944
+ # @param key [String] the dotted override key, for the error message.
945
+ # @param current [Object] the value at that path in the whole write.
946
+ # @param op [Hash] the operator Hash (`{"__op" => ...}`).
947
+ # @return [Object] the new value, or {FOLD_DELETE} to remove the key.
948
+ # @!visibility private
949
+ def fold_override_op(key, current, op)
950
+ name = op["__op"]
951
+ case name
952
+ when "Delete"
953
+ FOLD_DELETE
954
+ when "Increment"
955
+ amount = op["amount"]
956
+ unless amount.is_a?(Numeric) && (current.nil? || current.is_a?(Numeric))
957
+ raise Parse::Webhooks::ResponseError,
958
+ "before_save reply: cannot apply Increment to #{key} (#{current.class})"
959
+ end
960
+ (current || 0) + amount
961
+ when "Add", "AddUnique", "Remove"
962
+ objects = op["objects"]
963
+ unless objects.is_a?(Array) && (current.nil? || current.is_a?(Array))
964
+ raise Parse::Webhooks::ResponseError,
965
+ "before_save reply: cannot apply #{name} to #{key} (#{current.class})"
966
+ end
967
+ base = current || []
968
+ case name
969
+ when "Add" then base + objects
970
+ when "AddUnique" then base + objects.reject { |o| base.include?(o) }.uniq
971
+ else base.reject { |o| objects.include?(o) }
972
+ end
973
+ else
974
+ raise Parse::Webhooks::ResponseError,
975
+ "before_save reply: operator #{name.inspect} on #{key} cannot be combined " \
976
+ "with a whole write of its parent field"
977
+ end
978
+ end
979
+
980
+ # @!visibility private
981
+ def handler_assigned_acl?(obj)
982
+ return true if obj.instance_variable_get(:@_webhook_reply_acl) == true
983
+ obj.respond_to?(:webhook_handler_acl_assigned?) && obj.webhook_handler_acl_assigned?
984
+ end
985
+
986
+ # On a client create, an owner-based ACL policy (`:owner_else_private`,
987
+ # the shipped default, `:owner_else_public`, `:owner_but_public_read`)
988
+ # resolves its owner the way an SDK create `as:` the requesting user
989
+ # does: when the declared owner field holds no value, the user who made
990
+ # the request owns the record. A request without a user (anonymous, or
991
+ # master key) gets the policy's fallback. Leaves the object alone when
992
+ # the handler or the client already set an ACL.
993
+ # @!visibility private
994
+ def adopt_request_user_as_acl_owner!(payload, obj)
995
+ return unless payload && payload.original.nil?
996
+ if handler_assigned_acl?(obj)
997
+ # The handler's ACL is final: keep the save-time policy resolver
998
+ # from replacing it, even when it equals the default stamp.
999
+ obj.instance_variable_set(:@_acl_pristine, false)
1000
+ return
1001
+ end
1002
+ return unless obj.is_a?(Parse::Object) && obj.instance_variable_get(:@_acl_pristine)
1003
+ return if obj.instance_variable_get(:@_acl_owner_override)
1004
+ klass = obj.class
1005
+ return if klass.respond_to?(:builtin_acl_default_active?) && klass.builtin_acl_default_active?
1006
+ return unless klass.respond_to?(:acl_policy_setting) && klass.acl_policy_setting.to_s.start_with?("owner_")
1007
+ field = klass.acl_owner_field
1008
+ if field == :self
1009
+ adopt_self_owned_acl!(obj, klass)
1010
+ return
1011
+ end
1012
+ # A master-key request is not the user's own write.
1013
+ return if payload.master?
1014
+ user = payload.user
1015
+ return unless user.is_a?(Parse::User) && user.id.present?
1016
+ if field && obj.respond_to?(field)
1017
+ return if obj.send(field).present?
1018
+ end
1019
+ obj.instance_variable_set(:@_acl_owner_override, user)
1020
+ end
1021
+
1022
+ # A self-owned user (`acl_policy ..., owner: :self`) owns its own
1023
+ # record, never the requesting user. Its objectId is assigned by Parse
1024
+ # Server after beforeSave, and Parse Server adds the new user's own
1025
+ # read and write to the ACL of every `_User` create. So the reply
1026
+ # carries the policy's ACL without the owner entry (`{}`, or public
1027
+ # read under `:owner_but_public_read`), which Parse Server completes
1028
+ # with the user's own grant. The save-time resolver is skipped, since
1029
+ # it would pre-generate an objectId the server does not use.
1030
+ # @!visibility private
1031
+ def adopt_self_owned_acl!(obj, klass)
1032
+ acl = if klass.acl_policy_setting == :owner_but_public_read
1033
+ Parse::ACL.everyone(true, false)
1034
+ else
1035
+ Parse::ACL.new
1036
+ end
1037
+ klass.instance_method(:acl=).bind_call(obj, acl)
1038
+ obj.instance_variable_set(:@_acl_pristine, false)
1039
+ obj.instance_variable_set(:@_webhook_reply_acl, true)
1040
+ end
1041
+
825
1042
  # The client's write, rebuilt from a beforeSave payload. Parse Server
826
1043
  # serializes the pending object with `toJSON()`, which reports every
827
1044
  # pending top-level operator as its operator hash, so the operators
828
1045
  # survive here as sent. On an update, a field whose value equals the
829
- # stored one was not written by the client and is left out.
1046
+ # stored one was not written by the client and is left out, and a
1047
+ # changed sub-document is split into dotted sub-key writes (see
1048
+ # {sub_document_write}).
830
1049
  #
831
1050
  # @param raw_object [Hash] the unscrubbed `object` hash.
832
1051
  # @param raw_original [Hash, nil] the unscrubbed `original` hash.
@@ -841,12 +1060,96 @@ module Parse
841
1060
  # Parse Server drops objectId from an update write itself.
842
1061
  next if key == Parse::Model::OBJECT_ID
843
1062
  next if raw_original.key?(key) && raw_original[key] == value
1063
+ dotted = sub_document_write(key, value, raw_original[key])
1064
+ if dotted
1065
+ data.merge!(dotted)
1066
+ next
1067
+ end
844
1068
  end
845
1069
  data[key] = value
846
1070
  end
847
1071
  data
848
1072
  end
849
1073
 
1074
+ # Keys never split into dotted sub-key writes: their values are
1075
+ # replaced as a whole by Parse Server.
1076
+ # @!visibility private
1077
+ BEFORE_SAVE_REPLY_WHOLE_KEYS = %w[ACL authData].freeze
1078
+
1079
+ # Dotted sub-key writes for a sub-document the client changed.
1080
+ #
1081
+ # Parse Server applies a client's `"meta.count"` operator to its pending
1082
+ # object and sends the webhook only the resulting `meta`, which matches
1083
+ # a whole-object write of the same value. Writing just the sub-keys that
1084
+ # differ from the stored sub-document gives the same result as either
1085
+ # client write, without overwriting sub-keys another request changed in
1086
+ # the meantime. The split is one level deep: a changed sub-key is
1087
+ # written whole at `field.sub`, nested objects included, because Parse
1088
+ # Server rebuilds the afterSave object from a non-operator dotted key
1089
+ # only one level deep (`"meta.count.value"` would set `meta.count` to
1090
+ # the leaf). A sub-key missing from the new value is written as a
1091
+ # `Delete` operator.
1092
+ #
1093
+ # Parse Server stores a dotted value as sent, without the type transform
1094
+ # a whole write gets, so a Date would land as a plain sub-document
1095
+ # instead of a BSON date. When any write would carry a typed value
1096
+ # (`{"__type": ...}`), the field is written whole instead.
1097
+ #
1098
+ # @param key [String] the top-level field name.
1099
+ # @param value [Object] the field's value in the pending object.
1100
+ # @param stored [Object] the field's stored value.
1101
+ # @return [Hash, nil] the dotted writes, or nil to write the field whole.
1102
+ # @!visibility private
1103
+ def sub_document_write(key, value, stored)
1104
+ return nil if key.start_with?("_") || BEFORE_SAVE_REPLY_WHOLE_KEYS.include?(key)
1105
+ return nil unless splittable_level?(value, stored)
1106
+ writes = {}
1107
+ diff_sub_document(key, value, stored, writes)
1108
+ return nil if writes.empty? || writes.values.any? { |v| typed_value?(v) }
1109
+ writes
1110
+ end
1111
+
1112
+ # Collect the one-level dotted writes that turn `stored` into `value`
1113
+ # under `path`.
1114
+ # @!visibility private
1115
+ def diff_sub_document(path, value, stored, writes)
1116
+ (value.keys | stored.keys).each do |sub|
1117
+ new_present = value.key?(sub)
1118
+ old_present = stored.key?(sub)
1119
+ next if new_present && old_present && value[sub] == stored[sub]
1120
+ sub_path = "#{path}.#{sub}"
1121
+ writes[sub_path] = new_present ? value[sub] : { "__op" => "Delete" }
1122
+ end
1123
+ end
1124
+
1125
+ # Whether a pair of values can be diffed key by key: both plain JSON
1126
+ # objects, with sub-keys that are usable as path segments.
1127
+ # @!visibility private
1128
+ def splittable_level?(value, stored)
1129
+ return false unless plain_sub_document?(value) && plain_sub_document?(stored)
1130
+ keys = value.keys | stored.keys
1131
+ return false if keys.empty?
1132
+ keys.none? { |k| k.to_s.empty? || k.to_s.include?(".") || k.to_s.start_with?("$") }
1133
+ end
1134
+
1135
+ # Whether a JSON value is, or contains, a Parse typed value
1136
+ # (`{"__type": ...}`: Date, Bytes, Pointer, File, GeoPoint, ...).
1137
+ # @!visibility private
1138
+ def typed_value?(value)
1139
+ case value
1140
+ when Hash then value.key?("__type") || value.values.any? { |v| typed_value?(v) }
1141
+ when Array then value.any? { |v| typed_value?(v) }
1142
+ else false
1143
+ end
1144
+ end
1145
+
1146
+ # A JSON object field value: a Hash that is not a Parse operator or a
1147
+ # typed value (Pointer, Date, File, GeoPoint, ...).
1148
+ # @!visibility private
1149
+ def plain_sub_document?(value)
1150
+ value.is_a?(Hash) && !value.key?("__op") && !value.key?("__type")
1151
+ end
1152
+
850
1153
  # Diff the handler's object against a fresh build of the same payload.
851
1154
  #
852
1155
  # Only fields that are dirty on either object can differ: a field the
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.8.0
4
+ version: 5.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin