solid_objects 0.3.0 → 0.4.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 +10 -0
- data/README.md +134 -25
- data/app/controllers/solid_objects/components_controller.rb +78 -0
- data/app/helpers/solid_objects/actor_helper.rb +13 -4
- data/config/routes.rb +1 -0
- data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
- data/docs/architecture.md +61 -15
- data/docs/authorization.md +38 -1
- data/docs/correctness.md +28 -2
- data/docs/database-schema.md +13 -5
- data/docs/realtime.md +106 -12
- data/docs/roadmap.md +6 -5
- data/examples/application/README.md +5 -4
- data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
- data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
- data/examples/application/config/initializers/solid_objects.rb +9 -3
- data/lib/generators/solid_objects/templates/solid_objects.rb +3 -0
- data/lib/solid_objects/actor.rb +11 -0
- data/lib/solid_objects/actor_channel.rb +73 -5
- data/lib/solid_objects/actor_snapshot.rb +25 -6
- data/lib/solid_objects/actor_view.rb +103 -10
- data/lib/solid_objects/component_path_resolver.rb +27 -0
- data/lib/solid_objects/component_registration.rb +117 -0
- data/lib/solid_objects/component_renderer.rb +82 -0
- data/lib/solid_objects/component_subscriptions.rb +109 -0
- data/lib/solid_objects/component_token.rb +139 -0
- data/lib/solid_objects/component_view.rb +67 -0
- data/lib/solid_objects/configuration.rb +12 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +15 -3
- data/lib/solid_objects/errors.rb +12 -0
- data/lib/solid_objects/executor.rb +1 -0
- data/lib/solid_objects/stream_token.rb +32 -13
- data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +6 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
- data/sig/generated/lib/solid_objects/actor.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
- data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
- data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
- data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
- data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
- data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
- data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
- data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
- data/sig/generated/lib/solid_objects/errors.rbs +12 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
- metadata +16 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
class ComponentRegistration
|
|
7
|
+
# @rbs @reference: Reference
|
|
8
|
+
# @rbs @component_name: String
|
|
9
|
+
# @rbs @dependencies: Array[String]
|
|
10
|
+
# @rbs @instance_id: Integer
|
|
11
|
+
# @rbs @revision: Integer
|
|
12
|
+
# @rbs @refresh_path: String
|
|
13
|
+
# @rbs @token: String
|
|
14
|
+
|
|
15
|
+
attr_reader :reference,
|
|
16
|
+
:component_name,
|
|
17
|
+
:dependencies,
|
|
18
|
+
:instance_id,
|
|
19
|
+
:revision,
|
|
20
|
+
:refresh_path,
|
|
21
|
+
:token
|
|
22
|
+
|
|
23
|
+
# @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String, token: String) -> void
|
|
24
|
+
def initialize(
|
|
25
|
+
reference:,
|
|
26
|
+
component_name:,
|
|
27
|
+
dependencies:,
|
|
28
|
+
instance_id:,
|
|
29
|
+
revision:,
|
|
30
|
+
refresh_path:,
|
|
31
|
+
token:
|
|
32
|
+
)
|
|
33
|
+
@reference = reference
|
|
34
|
+
@component_name = component_name
|
|
35
|
+
@dependencies = dependencies.freeze
|
|
36
|
+
@instance_id = instance_id
|
|
37
|
+
@revision = revision
|
|
38
|
+
@refresh_path = refresh_path
|
|
39
|
+
@token = token
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# @rbs (reference: Reference, component_name: String, dependencies: Array[String], snapshot: ActorSnapshot, refresh_path: String) -> ComponentRegistration
|
|
44
|
+
def issue(reference:, component_name:, dependencies:, snapshot:, refresh_path:)
|
|
45
|
+
token = ComponentToken.generate(
|
|
46
|
+
reference:,
|
|
47
|
+
component_name:,
|
|
48
|
+
dependencies:,
|
|
49
|
+
instance_id: snapshot.instance_id,
|
|
50
|
+
revision: snapshot.revision,
|
|
51
|
+
refresh_path:
|
|
52
|
+
)
|
|
53
|
+
new(
|
|
54
|
+
reference:,
|
|
55
|
+
component_name:,
|
|
56
|
+
dependencies:,
|
|
57
|
+
instance_id: snapshot.instance_id,
|
|
58
|
+
revision: snapshot.revision,
|
|
59
|
+
refresh_path:,
|
|
60
|
+
token:
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @rbs (String) -> ComponentRegistration
|
|
65
|
+
def from_token(token)
|
|
66
|
+
payload = ComponentToken.verify(token)
|
|
67
|
+
reference = Reference.new(
|
|
68
|
+
actor_type: payload.fetch("actor_type"),
|
|
69
|
+
actor_id: payload.fetch("actor_id")
|
|
70
|
+
)
|
|
71
|
+
dependencies = payload.fetch("dependencies")
|
|
72
|
+
validate_dependencies!(reference, dependencies)
|
|
73
|
+
new(
|
|
74
|
+
reference:,
|
|
75
|
+
component_name: payload.fetch("component_name"),
|
|
76
|
+
dependencies:,
|
|
77
|
+
instance_id: payload.fetch("instance_id"),
|
|
78
|
+
revision: payload.fetch("revision"),
|
|
79
|
+
refresh_path: payload.fetch("refresh_path"),
|
|
80
|
+
token:
|
|
81
|
+
)
|
|
82
|
+
rescue UnknownActorType, UnknownComponentDependency => error
|
|
83
|
+
raise InvalidComponentToken, error.message
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# @rbs (Reference, Array[String]) -> void
|
|
89
|
+
def validate_dependencies!(reference, dependencies)
|
|
90
|
+
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
91
|
+
observables = actor_class.definition.observables
|
|
92
|
+
unknown = dependencies.find do |dependency|
|
|
93
|
+
!observables.key?(dependency.to_sym)
|
|
94
|
+
end
|
|
95
|
+
return unless unknown
|
|
96
|
+
|
|
97
|
+
raise UnknownComponentDependency,
|
|
98
|
+
"unknown observable dependency #{unknown.inspect} for #{reference.actor_type}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @rbs () -> Array[Integer]
|
|
103
|
+
def revision_key
|
|
104
|
+
[ instance_id, revision ]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @rbs (Integer, Integer) -> String
|
|
108
|
+
def refresh_url(instance_id, revision)
|
|
109
|
+
query = URI.encode_www_form(
|
|
110
|
+
token:,
|
|
111
|
+
instance_id:,
|
|
112
|
+
revision:
|
|
113
|
+
)
|
|
114
|
+
"#{refresh_path}?#{query}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ComponentRenderer
|
|
5
|
+
# @rbs @snapshot: ActorSnapshot
|
|
6
|
+
# @rbs @component_name: String
|
|
7
|
+
# @rbs @dependencies: Array[String]
|
|
8
|
+
# @rbs @view_context: untyped
|
|
9
|
+
# @rbs @authorization_context: untyped
|
|
10
|
+
|
|
11
|
+
# @rbs (snapshot: ActorSnapshot, component_name: String, dependencies: Array[String], view_context: untyped, authorization_context: untyped) -> void
|
|
12
|
+
def initialize(
|
|
13
|
+
snapshot:,
|
|
14
|
+
component_name:,
|
|
15
|
+
dependencies:,
|
|
16
|
+
view_context:,
|
|
17
|
+
authorization_context:
|
|
18
|
+
)
|
|
19
|
+
@snapshot = snapshot
|
|
20
|
+
@component_name = component_name
|
|
21
|
+
@dependencies = dependencies
|
|
22
|
+
@view_context = view_context
|
|
23
|
+
@authorization_context = authorization_context
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @rbs () -> untyped
|
|
27
|
+
def call
|
|
28
|
+
[ component_name, *dependencies ].uniq.each do |authorization_name|
|
|
29
|
+
authorize_read!(authorization_name)
|
|
30
|
+
end
|
|
31
|
+
actor = ComponentView.new(
|
|
32
|
+
snapshot:,
|
|
33
|
+
dependencies:,
|
|
34
|
+
authorization_context:
|
|
35
|
+
)
|
|
36
|
+
view_context.render(
|
|
37
|
+
partial: default_partial,
|
|
38
|
+
locals: {
|
|
39
|
+
actor:,
|
|
40
|
+
authorization_context:
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
rescue ActionView::MissingTemplate
|
|
44
|
+
raise UnknownComponent,
|
|
45
|
+
"unknown component #{component_name.inspect} for #{snapshot.reference.actor_type}"
|
|
46
|
+
rescue ActionView::Template::Error => error
|
|
47
|
+
raise error.cause if error.cause.is_a?(SolidObjects::Error)
|
|
48
|
+
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :snapshot,
|
|
55
|
+
:component_name,
|
|
56
|
+
:dependencies,
|
|
57
|
+
:view_context,
|
|
58
|
+
:authorization_context
|
|
59
|
+
|
|
60
|
+
# @rbs (String) -> void
|
|
61
|
+
def authorize_read!(observable_name)
|
|
62
|
+
authorized = SolidObjects.configuration.authorize_query.call(
|
|
63
|
+
actor_type: snapshot.reference.actor_type,
|
|
64
|
+
actor_id: snapshot.reference.actor_id,
|
|
65
|
+
message_name: observable_name,
|
|
66
|
+
arguments: {},
|
|
67
|
+
authorization_context:
|
|
68
|
+
)
|
|
69
|
+
return if authorized
|
|
70
|
+
|
|
71
|
+
raise Unauthorized, "actor component query is not authorized"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @rbs () -> String
|
|
75
|
+
def default_partial
|
|
76
|
+
actor_name = snapshot.actor_class.name
|
|
77
|
+
raise InvalidActor, "anonymous actors cannot render reactive components" unless actor_name
|
|
78
|
+
|
|
79
|
+
"actors/#{actor_name.underscore}/#{component_name}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ComponentSubscriptions
|
|
5
|
+
MAXIMUM_COMPONENTS = 50
|
|
6
|
+
MAXIMUM_SERIALIZED_BYTES = 1_048_576
|
|
7
|
+
|
|
8
|
+
# @rbs @registrations: Array[ComponentRegistration]
|
|
9
|
+
# @rbs @revisions: Hash[String, Array[Integer]]
|
|
10
|
+
|
|
11
|
+
# @rbs (String?, reference: Reference) -> ComponentSubscriptions
|
|
12
|
+
def self.parse(serialized, reference:)
|
|
13
|
+
return new([]) unless serialized
|
|
14
|
+
unless serialized.is_a?(String) &&
|
|
15
|
+
serialized.bytesize <= MAXIMUM_SERIALIZED_BYTES
|
|
16
|
+
raise InvalidComponentToken, "invalid actor component registrations"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
tokens = JSON.parse(serialized)
|
|
20
|
+
unless tokens.is_a?(Array) &&
|
|
21
|
+
tokens.length <= MAXIMUM_COMPONENTS &&
|
|
22
|
+
tokens.all? { |token| token.is_a?(String) }
|
|
23
|
+
raise InvalidComponentToken, "invalid actor component registrations"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
registrations = tokens.map do |token|
|
|
27
|
+
ComponentRegistration.from_token(token).tap do |registration|
|
|
28
|
+
validate_identity!(registration, reference)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
if registrations.map(&:component_name).uniq.length != registrations.length
|
|
32
|
+
raise InvalidComponentToken, "duplicate actor component registration"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
new(registrations)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @rbs (Array[ComponentRegistration]) -> void
|
|
39
|
+
def initialize(registrations)
|
|
40
|
+
@registrations = registrations
|
|
41
|
+
@revisions = registrations.to_h do |registration|
|
|
42
|
+
[ registration.component_name, registration.revision_key ]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @rbs (Hash[String, untyped]) -> Array[String]
|
|
47
|
+
def refreshes_for(invalidation)
|
|
48
|
+
observable_name = invalidation.fetch("observable_name")
|
|
49
|
+
instance_id = invalidation.fetch("instance_id")
|
|
50
|
+
revision = invalidation.fetch("revision")
|
|
51
|
+
registrations.filter_map do |registration|
|
|
52
|
+
next unless registration.dependencies.include?(observable_name)
|
|
53
|
+
next unless newer_revision?(
|
|
54
|
+
registration.component_name,
|
|
55
|
+
instance_id,
|
|
56
|
+
revision
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
refresh(registration, instance_id, revision)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @rbs (ActorSnapshot) -> Array[String]
|
|
64
|
+
def reconnect_refreshes(snapshot)
|
|
65
|
+
registrations.filter_map do |registration|
|
|
66
|
+
next unless newer_revision?(
|
|
67
|
+
registration.component_name,
|
|
68
|
+
snapshot.instance_id,
|
|
69
|
+
snapshot.revision
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
refresh(registration, snapshot.instance_id, snapshot.revision)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# @rbs (ComponentRegistration, Reference) -> void
|
|
80
|
+
def validate_identity!(registration, reference)
|
|
81
|
+
return if registration.reference.actor_type == reference.actor_type &&
|
|
82
|
+
registration.reference.actor_id == reference.actor_id
|
|
83
|
+
|
|
84
|
+
raise InvalidComponentToken,
|
|
85
|
+
"actor component identity does not match its subscription"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
attr_reader :registrations, :revisions
|
|
92
|
+
|
|
93
|
+
# @rbs (ComponentRegistration, Integer, Integer) -> String
|
|
94
|
+
def refresh(registration, instance_id, revision)
|
|
95
|
+
revisions[registration.component_name] = [ instance_id, revision ]
|
|
96
|
+
TurboStreamRenderer.component_refresh(
|
|
97
|
+
registration,
|
|
98
|
+
instance_id,
|
|
99
|
+
revision
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @rbs (String, Integer, Integer) -> bool
|
|
104
|
+
def newer_revision?(component_name, instance_id, revision)
|
|
105
|
+
current = revisions.fetch(component_name)
|
|
106
|
+
(current <=> [ instance_id, revision ]) == -1
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
require "active_support/message_verifier"
|
|
4
|
+
|
|
5
|
+
module SolidObjects
|
|
6
|
+
module ComponentToken
|
|
7
|
+
PURPOSE = "solid_objects.actor_component"
|
|
8
|
+
MAXIMUM_TOKEN_BYTES = 16_384
|
|
9
|
+
MAXIMUM_DEPENDENCIES = 50
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# @rbs (reference: Reference, component_name: String, dependencies: Array[String], instance_id: Integer, revision: Integer, refresh_path: String) -> String
|
|
14
|
+
def generate(
|
|
15
|
+
reference:,
|
|
16
|
+
component_name:,
|
|
17
|
+
dependencies:,
|
|
18
|
+
instance_id:,
|
|
19
|
+
revision:,
|
|
20
|
+
refresh_path:
|
|
21
|
+
)
|
|
22
|
+
payload = {
|
|
23
|
+
"actor_type" => reference.actor_type,
|
|
24
|
+
"actor_id" => reference.actor_id,
|
|
25
|
+
"component_name" => component_name,
|
|
26
|
+
"dependencies" => dependencies,
|
|
27
|
+
"instance_id" => instance_id,
|
|
28
|
+
"revision" => revision,
|
|
29
|
+
"refresh_path" => refresh_path
|
|
30
|
+
}
|
|
31
|
+
validate_payload!(payload)
|
|
32
|
+
verifier.generate(payload, purpose: PURPOSE).tap do |token|
|
|
33
|
+
raise InvalidComponentToken, "component token is too large" if token.bytesize > MAXIMUM_TOKEN_BYTES
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @rbs (String) -> Hash[String, untyped]
|
|
38
|
+
def verify(token)
|
|
39
|
+
unless token.is_a?(String) && token.bytesize <= MAXIMUM_TOKEN_BYTES
|
|
40
|
+
raise InvalidComponentToken, "invalid actor component token"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
payload = verifier.verified(token, purpose: PURPOSE)
|
|
44
|
+
validate_payload!(payload)
|
|
45
|
+
payload
|
|
46
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
47
|
+
raise InvalidComponentToken, "invalid actor component token"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @rbs (Hash[String, untyped]) -> Hash[String, untyped]
|
|
51
|
+
def validate_payload!(payload)
|
|
52
|
+
unless payload.is_a?(Hash) &&
|
|
53
|
+
payload["actor_type"].is_a?(String) &&
|
|
54
|
+
payload["actor_id"].is_a?(String) &&
|
|
55
|
+
payload["component_name"].is_a?(String) &&
|
|
56
|
+
payload["dependencies"].is_a?(Array) &&
|
|
57
|
+
payload["instance_id"].is_a?(Integer) &&
|
|
58
|
+
payload["revision"].is_a?(Integer) &&
|
|
59
|
+
payload["refresh_path"].is_a?(String)
|
|
60
|
+
raise InvalidComponentToken, "invalid actor component token"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
validate_component_name!(payload.fetch("component_name"))
|
|
64
|
+
validate_dependencies!(payload.fetch("dependencies"))
|
|
65
|
+
validate_revision!(
|
|
66
|
+
payload.fetch("instance_id"),
|
|
67
|
+
payload.fetch("revision")
|
|
68
|
+
)
|
|
69
|
+
validate_refresh_path!(payload.fetch("refresh_path"))
|
|
70
|
+
payload
|
|
71
|
+
end
|
|
72
|
+
private_class_method :validate_payload!
|
|
73
|
+
|
|
74
|
+
# @rbs (String) -> void
|
|
75
|
+
def validate_component_name!(component_name)
|
|
76
|
+
return if component_name.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
77
|
+
|
|
78
|
+
raise InvalidComponentToken, "invalid actor component name"
|
|
79
|
+
end
|
|
80
|
+
private_class_method :validate_component_name!
|
|
81
|
+
|
|
82
|
+
# @rbs (Array[untyped]) -> void
|
|
83
|
+
def validate_dependencies!(dependencies)
|
|
84
|
+
valid = dependencies.any? &&
|
|
85
|
+
dependencies.length <= MAXIMUM_DEPENDENCIES &&
|
|
86
|
+
dependencies.uniq.length == dependencies.length &&
|
|
87
|
+
dependencies.all? { |dependency| dependency.is_a?(String) && dependency.match?(/\A[a-zA-Z0-9_]+\z/) }
|
|
88
|
+
return if valid
|
|
89
|
+
|
|
90
|
+
raise InvalidComponentToken, "invalid actor component dependencies"
|
|
91
|
+
end
|
|
92
|
+
private_class_method :validate_dependencies!
|
|
93
|
+
|
|
94
|
+
# @rbs (Integer, Integer) -> void
|
|
95
|
+
def validate_revision!(instance_id, revision)
|
|
96
|
+
return unless instance_id.negative? || revision.negative?
|
|
97
|
+
|
|
98
|
+
raise InvalidComponentToken, "invalid actor component revision"
|
|
99
|
+
end
|
|
100
|
+
private_class_method :validate_revision!
|
|
101
|
+
|
|
102
|
+
# @rbs (String) -> void
|
|
103
|
+
def validate_refresh_path!(refresh_path)
|
|
104
|
+
valid = refresh_path.bytesize <= 2_048 &&
|
|
105
|
+
refresh_path.start_with?("/") &&
|
|
106
|
+
!refresh_path.start_with?("//") &&
|
|
107
|
+
!refresh_path.include?("\0") &&
|
|
108
|
+
!refresh_path.include?("?") &&
|
|
109
|
+
!refresh_path.include?("#")
|
|
110
|
+
return if valid
|
|
111
|
+
|
|
112
|
+
raise InvalidComponentToken, "invalid actor component refresh path"
|
|
113
|
+
end
|
|
114
|
+
private_class_method :validate_refresh_path!
|
|
115
|
+
|
|
116
|
+
# @rbs () -> ActiveSupport::MessageVerifier
|
|
117
|
+
def verifier
|
|
118
|
+
ActiveSupport::MessageVerifier.new(
|
|
119
|
+
signing_secret,
|
|
120
|
+
digest: "SHA256",
|
|
121
|
+
serializer: JSON
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
private_class_method :verifier
|
|
125
|
+
|
|
126
|
+
# @rbs () -> String
|
|
127
|
+
def signing_secret
|
|
128
|
+
configured_secret = SolidObjects.configuration.stream_signing_secret
|
|
129
|
+
return configured_secret if configured_secret
|
|
130
|
+
|
|
131
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
132
|
+
return Rails.application.key_generator.generate_key(PURPOSE, 64)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
raise ArgumentError, "configure stream_signing_secret outside a Rails application"
|
|
136
|
+
end
|
|
137
|
+
private_class_method :signing_secret
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ComponentView
|
|
5
|
+
# @rbs @snapshot: ActorSnapshot
|
|
6
|
+
# @rbs @dependencies: Array[String]
|
|
7
|
+
# @rbs @authorization_context: untyped
|
|
8
|
+
|
|
9
|
+
attr_reader :authorization_context
|
|
10
|
+
|
|
11
|
+
# @rbs (snapshot: ActorSnapshot, dependencies: Array[String], authorization_context: untyped) -> void
|
|
12
|
+
def initialize(snapshot:, dependencies:, authorization_context:)
|
|
13
|
+
@snapshot = snapshot
|
|
14
|
+
@dependencies = dependencies
|
|
15
|
+
@authorization_context = authorization_context
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @rbs () -> Reference
|
|
19
|
+
def reference
|
|
20
|
+
snapshot.reference
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @rbs () -> String
|
|
24
|
+
def actor_id
|
|
25
|
+
reference.actor_id
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @rbs () -> void
|
|
29
|
+
def state
|
|
30
|
+
raise UnknownComponentDependency,
|
|
31
|
+
"reactive components must read declared observables instead of actor.state"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @rbs (Symbol, *untyped, **untyped) -> untyped
|
|
35
|
+
def method_missing(name, *arguments, **keywords)
|
|
36
|
+
return super unless arguments.empty? && keywords.empty?
|
|
37
|
+
return observable_value(name) if observable?(name)
|
|
38
|
+
|
|
39
|
+
super
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @rbs (Symbol, bool) -> bool
|
|
43
|
+
def respond_to_missing?(name, include_private = false)
|
|
44
|
+
observable?(name) || super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :snapshot, :dependencies
|
|
50
|
+
|
|
51
|
+
# @rbs (Symbol | String) -> bool
|
|
52
|
+
def observable?(name)
|
|
53
|
+
snapshot.actor_class.definition.observables.key?(name.to_sym)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @rbs (Symbol | String) -> untyped
|
|
57
|
+
def observable_value(name)
|
|
58
|
+
dependency = name.to_s
|
|
59
|
+
unless dependencies.include?(dependency)
|
|
60
|
+
raise UnknownComponentDependency,
|
|
61
|
+
"observable #{dependency.inspect} is not declared by this component"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
snapshot.observable_value(dependency)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -34,6 +34,8 @@ module SolidObjects
|
|
|
34
34
|
# @rbs @stream_signing_secret: String?
|
|
35
35
|
# @rbs @broadcast_adapter: Proc?
|
|
36
36
|
# @rbs @wake_up_adapter: untyped
|
|
37
|
+
# @rbs @component_path_resolver: Proc?
|
|
38
|
+
# @rbs @component_authorization_context: Proc
|
|
37
39
|
# @rbs @authorize_message: Proc
|
|
38
40
|
# @rbs @authorize_query: Proc
|
|
39
41
|
# @rbs @authorize_destroy: Proc
|
|
@@ -72,6 +74,8 @@ module SolidObjects
|
|
|
72
74
|
:stream_signing_secret,
|
|
73
75
|
:broadcast_adapter,
|
|
74
76
|
:wake_up_adapter,
|
|
77
|
+
:component_path_resolver,
|
|
78
|
+
:component_authorization_context,
|
|
75
79
|
:authorize_message,
|
|
76
80
|
:authorize_query,
|
|
77
81
|
:authorize_destroy,
|
|
@@ -111,6 +115,8 @@ module SolidObjects
|
|
|
111
115
|
@stream_signing_secret = nil
|
|
112
116
|
@broadcast_adapter = nil
|
|
113
117
|
@wake_up_adapter = nil
|
|
118
|
+
@component_path_resolver = nil
|
|
119
|
+
@component_authorization_context = ->(controller:) { controller }
|
|
114
120
|
@logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
115
121
|
Rails.logger
|
|
116
122
|
else
|
|
@@ -151,6 +157,12 @@ module SolidObjects
|
|
|
151
157
|
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
|
|
152
158
|
raise ArgumentError, "instance retention must be positive" unless retention.positive?
|
|
153
159
|
end
|
|
160
|
+
unless component_path_resolver.nil? || component_path_resolver.respond_to?(:call)
|
|
161
|
+
raise ArgumentError, "component_path_resolver must respond to call"
|
|
162
|
+
end
|
|
163
|
+
unless component_authorization_context.respond_to?(:call)
|
|
164
|
+
raise ArgumentError, "component_authorization_context must respond to call"
|
|
165
|
+
end
|
|
154
166
|
|
|
155
167
|
self
|
|
156
168
|
end
|
|
@@ -3,11 +3,25 @@
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
module DatabaseAdapters
|
|
5
5
|
class Sqlite < DatabaseAdapter
|
|
6
|
+
LOCK_RETRY_INTERVAL = 0.001
|
|
7
|
+
|
|
6
8
|
# @rbs () -> String
|
|
7
9
|
def current_time_expression
|
|
8
10
|
"STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
|
|
9
11
|
end
|
|
10
12
|
|
|
13
|
+
# @rbs () { () -> untyped } -> untyped
|
|
14
|
+
def transaction(&block)
|
|
15
|
+
return super unless SyncDeadline.active?
|
|
16
|
+
|
|
17
|
+
super
|
|
18
|
+
rescue DatabaseDeadlineExceeded
|
|
19
|
+
raise if SyncDeadline.expired?
|
|
20
|
+
|
|
21
|
+
sleep [ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
|
|
22
|
+
retry
|
|
23
|
+
end
|
|
24
|
+
|
|
11
25
|
private
|
|
12
26
|
|
|
13
27
|
# @rbs (untyped) { () -> untyped } -> untyped
|
|
@@ -15,9 +29,7 @@ module SolidObjects
|
|
|
15
29
|
return yield unless SyncDeadline.active?
|
|
16
30
|
|
|
17
31
|
previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
|
|
18
|
-
connection.execute(
|
|
19
|
-
"PRAGMA busy_timeout = #{SyncDeadline.remaining_milliseconds}"
|
|
20
|
-
)
|
|
32
|
+
connection.execute("PRAGMA busy_timeout = 0")
|
|
21
33
|
yield
|
|
22
34
|
ensure
|
|
23
35
|
connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout
|
data/lib/solid_objects/errors.rb
CHANGED
|
@@ -37,6 +37,18 @@ module SolidObjects
|
|
|
37
37
|
class InvalidStreamToken < Error
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
class InvalidComponentToken < Error
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class UnknownComponent < Error
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class UnknownComponentDependency < Error
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class UnsupportedComponentRendering < Error
|
|
50
|
+
end
|
|
51
|
+
|
|
40
52
|
class LostActivation < Error
|
|
41
53
|
end
|
|
42
54
|
|
|
@@ -5,33 +5,52 @@ require "active_support/message_verifier"
|
|
|
5
5
|
module SolidObjects
|
|
6
6
|
module StreamToken
|
|
7
7
|
PURPOSE = "solid_objects.actor_stream"
|
|
8
|
+
MAXIMUM_OBSERVABLES = 100
|
|
8
9
|
|
|
9
10
|
module_function
|
|
10
11
|
|
|
11
|
-
# @rbs (Reference) -> String
|
|
12
|
-
def generate(reference)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
)
|
|
12
|
+
# @rbs (Reference, ?observables: Array[String]?) -> String
|
|
13
|
+
def generate(reference, observables: nil)
|
|
14
|
+
identity = {
|
|
15
|
+
"actor_type" => reference.actor_type,
|
|
16
|
+
"actor_id" => reference.actor_id
|
|
17
|
+
}
|
|
18
|
+
identity["observables"] = observables if observables
|
|
19
|
+
validate_identity!(identity)
|
|
20
|
+
verifier.generate(identity, purpose: PURPOSE)
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
# @rbs (String) -> Hash[String,
|
|
23
|
+
# @rbs (String) -> Hash[String, untyped]
|
|
23
24
|
def verify(token)
|
|
24
25
|
identity = verifier.verified(token, purpose: PURPOSE)
|
|
26
|
+
validate_identity!(identity)
|
|
27
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
28
|
+
raise InvalidStreamToken, "invalid actor stream token"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (Hash[String, untyped]) -> Hash[String, untyped]
|
|
32
|
+
def validate_identity!(identity)
|
|
25
33
|
unless identity.is_a?(Hash) &&
|
|
26
34
|
identity["actor_type"].is_a?(String) &&
|
|
27
35
|
identity["actor_id"].is_a?(String)
|
|
28
36
|
raise InvalidStreamToken, "invalid actor stream token"
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
identity
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
observables = identity["observables"]
|
|
40
|
+
return identity unless observables
|
|
41
|
+
|
|
42
|
+
valid = observables.is_a?(Array) &&
|
|
43
|
+
observables.length <= MAXIMUM_OBSERVABLES &&
|
|
44
|
+
observables.uniq.length == observables.length &&
|
|
45
|
+
observables.all? do |observable|
|
|
46
|
+
observable.is_a?(String) &&
|
|
47
|
+
observable.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
48
|
+
end
|
|
49
|
+
return identity if valid
|
|
50
|
+
|
|
51
|
+
raise InvalidStreamToken, "invalid actor stream observables"
|
|
34
52
|
end
|
|
53
|
+
private_class_method :validate_identity!
|
|
35
54
|
|
|
36
55
|
# @rbs () -> ActiveSupport::MessageVerifier
|
|
37
56
|
def verifier
|