solid_objects 0.2.1 → 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 +24 -0
- data/README.md +261 -31
- 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 +116 -23
- data/docs/authorization.md +53 -3
- data/docs/benchmarks.md +6 -2
- data/docs/correctness.md +45 -1
- data/docs/database-schema.md +18 -5
- data/docs/development.md +40 -0
- data/docs/fit.md +10 -2
- data/docs/migrating-existing-state.md +8 -1
- data/docs/operations.md +74 -36
- data/docs/realtime.md +106 -12
- data/docs/roadmap.md +14 -7
- data/docs/security.md +25 -3
- data/docs/state-migrations.md +2 -0
- 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 +33 -2
- data/lib/solid_objects/activation.rb +33 -4
- data/lib/solid_objects/actor.rb +58 -6
- data/lib/solid_objects/actor_channel.rb +73 -5
- data/lib/solid_objects/actor_definition.rb +2 -0
- data/lib/solid_objects/actor_snapshot.rb +35 -10
- data/lib/solid_objects/actor_view.rb +103 -10
- data/lib/solid_objects/application_write_guard.rb +24 -0
- data/lib/solid_objects/caller_process.rb +28 -0
- data/lib/solid_objects/cli.rb +44 -5
- data/lib/solid_objects/client.rb +98 -5
- data/lib/solid_objects/commit_action_registry.rb +42 -0
- 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 +39 -1
- data/lib/solid_objects/database_adapter.rb +28 -1
- data/lib/solid_objects/database_adapters/mysql.rb +46 -0
- data/lib/solid_objects/database_adapters/postgresql.rb +29 -0
- data/lib/solid_objects/database_adapters/sqlite.rb +40 -0
- data/lib/solid_objects/errors.rb +156 -0
- data/lib/solid_objects/executor.rb +65 -4
- data/lib/solid_objects/instance_pruner.rb +97 -0
- data/lib/solid_objects/message_pruner.rb +97 -0
- data/lib/solid_objects/message_reference.rb +9 -0
- data/lib/solid_objects/process_pruner.rb +49 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/state_snapshot.rb +41 -0
- data/lib/solid_objects/stream_token.rb +32 -13
- data/lib/solid_objects/sync_deadline.rb +57 -0
- data/lib/solid_objects/sync_diagnostics.rb +133 -0
- data/lib/solid_objects/synchronous_invocation.rb +26 -7
- data/lib/solid_objects/test_helper.rb +78 -0
- data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/worker.rb +1 -1
- data/lib/solid_objects.rb +40 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
- data/sig/generated/lib/solid_objects/activation.rbs +3 -0
- data/sig/generated/lib/solid_objects/actor.rbs +29 -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/application_write_guard.rbs +8 -0
- data/sig/generated/lib/solid_objects/caller_process.rbs +11 -0
- data/sig/generated/lib/solid_objects/cli.rbs +11 -2
- data/sig/generated/lib/solid_objects/client.rbs +15 -0
- data/sig/generated/lib/solid_objects/commit_action_registry.rbs +43 -0
- 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 +35 -7
- data/sig/generated/lib/solid_objects/database_adapter.rbs +9 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
- data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +13 -0
- data/sig/generated/lib/solid_objects/errors.rbs +130 -0
- data/sig/generated/lib/solid_objects/executor.rbs +12 -0
- data/sig/generated/lib/solid_objects/instance_pruner.rbs +36 -0
- data/sig/generated/lib/solid_objects/message_pruner.rbs +42 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/process_pruner.rbs +27 -0
- data/sig/generated/lib/solid_objects/reference.rbs +3 -0
- data/sig/generated/lib/solid_objects/state_snapshot.rbs +30 -0
- data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
- data/sig/generated/lib/solid_objects/sync_deadline.rbs +31 -0
- data/sig/generated/lib/solid_objects/sync_diagnostics.rbs +34 -0
- data/sig/generated/lib/solid_objects/synchronous_invocation.rbs +3 -0
- data/sig/generated/lib/solid_objects/test_helper.rbs +25 -0
- data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
- data/sig/generated/lib/solid_objects.rbs +12 -0
- metadata +34 -1
data/lib/solid_objects/client.rb
CHANGED
|
@@ -50,16 +50,57 @@ module SolidObjects
|
|
|
50
50
|
arguments,
|
|
51
51
|
authorization_context:
|
|
52
52
|
)
|
|
53
|
-
|
|
53
|
+
reject_sync_inside_transaction!(reference, message_name)
|
|
54
|
+
SyncDeadline.with(timeout:) do
|
|
55
|
+
message_reference = enqueue_sync(
|
|
56
|
+
reference,
|
|
57
|
+
message_name,
|
|
58
|
+
arguments,
|
|
59
|
+
idempotency_key:,
|
|
60
|
+
timeout:
|
|
61
|
+
)
|
|
62
|
+
SynchronousInvocation.new.call(message_reference, timeout:)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped
|
|
67
|
+
def wait(message_reference, timeout:, authorization_context: nil)
|
|
68
|
+
message = Message.find(message_reference.id)
|
|
69
|
+
validate_message_reference!(message_reference, message)
|
|
70
|
+
reference = Reference.new(
|
|
71
|
+
actor_type: message.actor_type,
|
|
72
|
+
actor_id: message.actor_id
|
|
73
|
+
)
|
|
74
|
+
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
75
|
+
query = actor_class.definition.queries.key?(message.message_name.to_sym)
|
|
76
|
+
actor_message = actor_class.definition.messages.key?(message.message_name.to_sym)
|
|
77
|
+
unless query || actor_message
|
|
78
|
+
raise UnknownMessage, "unknown message #{message.message_name.inspect}"
|
|
79
|
+
end
|
|
80
|
+
authorize!(
|
|
81
|
+
query ? SolidObjects.configuration.authorize_query : SolidObjects.configuration.authorize_message,
|
|
54
82
|
reference,
|
|
55
|
-
message_name,
|
|
56
|
-
arguments,
|
|
57
|
-
|
|
58
|
-
idempotency_key:
|
|
83
|
+
message.message_name,
|
|
84
|
+
message.arguments,
|
|
85
|
+
authorization_context:
|
|
59
86
|
)
|
|
87
|
+
reject_sync_inside_transaction!(reference, message.message_name)
|
|
60
88
|
SynchronousInvocation.new.call(message_reference, timeout:)
|
|
61
89
|
end
|
|
62
90
|
|
|
91
|
+
# @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot
|
|
92
|
+
def snapshot(reference, authorization_context: nil)
|
|
93
|
+
SolidObjects.registry.fetch(reference.actor_type)
|
|
94
|
+
authorize!(
|
|
95
|
+
SolidObjects.configuration.authorize_query,
|
|
96
|
+
reference,
|
|
97
|
+
"__snapshot__",
|
|
98
|
+
{},
|
|
99
|
+
authorization_context:
|
|
100
|
+
)
|
|
101
|
+
StateSnapshot.new(reference)
|
|
102
|
+
end
|
|
103
|
+
|
|
63
104
|
# @rbs (Reference, ?authorization_context: untyped) -> bool
|
|
64
105
|
def destroy(reference, authorization_context: nil)
|
|
65
106
|
raise ActorCallCycle, "actors cannot synchronously destroy another actor" if Context.current_actor
|
|
@@ -93,6 +134,58 @@ module SolidObjects
|
|
|
93
134
|
|
|
94
135
|
attr_reader :mailbox
|
|
95
136
|
|
|
137
|
+
# @rbs (Reference, Symbol | String, Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference
|
|
138
|
+
def enqueue_sync(reference, message_name, arguments, idempotency_key:, timeout:)
|
|
139
|
+
mailbox.enqueue(
|
|
140
|
+
reference,
|
|
141
|
+
message_name,
|
|
142
|
+
arguments,
|
|
143
|
+
kind: "sync",
|
|
144
|
+
idempotency_key:
|
|
145
|
+
)
|
|
146
|
+
rescue DatabaseDeadlineExceeded
|
|
147
|
+
SolidObjects.instrument(
|
|
148
|
+
:"sync.enqueue_timeout",
|
|
149
|
+
actor_type: reference.actor_type,
|
|
150
|
+
actor_id: reference.actor_id,
|
|
151
|
+
message_name: message_name.to_s
|
|
152
|
+
)
|
|
153
|
+
raise SyncEnqueueTimeout.new(
|
|
154
|
+
timeout:,
|
|
155
|
+
actor_type: reference.actor_type,
|
|
156
|
+
actor_id: reference.actor_id,
|
|
157
|
+
message_name: message_name.to_s
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @rbs (MessageReference, Message) -> void
|
|
162
|
+
def validate_message_reference!(message_reference, message)
|
|
163
|
+
valid = message_reference.request_id == message.request_id &&
|
|
164
|
+
message_reference.actor_type == message.actor_type &&
|
|
165
|
+
message_reference.actor_id == message.actor_id &&
|
|
166
|
+
message_reference.sequence == message.sequence
|
|
167
|
+
return if valid
|
|
168
|
+
|
|
169
|
+
raise ActorDestroyed, "message reference no longer identifies this invocation"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @rbs (Reference, Symbol | String) -> void
|
|
173
|
+
def reject_sync_inside_transaction!(reference, message_name)
|
|
174
|
+
return unless SolidObjects::Record.connection.transaction_open?
|
|
175
|
+
|
|
176
|
+
SolidObjects.instrument(
|
|
177
|
+
:"sync.transaction_rejected",
|
|
178
|
+
actor_type: reference.actor_type,
|
|
179
|
+
actor_id: reference.actor_id,
|
|
180
|
+
message_name: message_name.to_s
|
|
181
|
+
)
|
|
182
|
+
raise SyncInsideTransaction.new(
|
|
183
|
+
actor_type: reference.actor_type,
|
|
184
|
+
actor_id: reference.actor_id,
|
|
185
|
+
message_name: message_name.to_s
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
96
189
|
# @rbs (Proc, Reference, Symbol | String, Hash[Symbol | String, untyped], authorization_context: untyped) -> void
|
|
97
190
|
def authorize!(hook, reference, message_name, arguments, authorization_context:)
|
|
98
191
|
authorized = hook.call(
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
CommitActionContext = Data.define(
|
|
5
|
+
:message_id,
|
|
6
|
+
:request_id,
|
|
7
|
+
:actor_type,
|
|
8
|
+
:actor_id,
|
|
9
|
+
:activation_generation
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
class CommitActionRegistry
|
|
13
|
+
# @rbs @handlers: Hash[String, Proc]
|
|
14
|
+
# @rbs @mutex: Mutex
|
|
15
|
+
|
|
16
|
+
# @rbs () -> void
|
|
17
|
+
def initialize
|
|
18
|
+
@handlers = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs (String | Symbol, Proc) -> Proc
|
|
23
|
+
def register(name, handler)
|
|
24
|
+
action_name = name.to_s
|
|
25
|
+
raise ArgumentError, "commit action name cannot be empty" if action_name.empty?
|
|
26
|
+
|
|
27
|
+
mutex.synchronize { handlers[action_name] = handler }
|
|
28
|
+
handler
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @rbs (String | Symbol) -> Proc
|
|
32
|
+
def fetch(name)
|
|
33
|
+
handlers.fetch(name.to_s) do
|
|
34
|
+
raise UnknownCommitAction, "no commit action registered for #{name.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :handlers, :mutex
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class ComponentPathResolver
|
|
5
|
+
# @rbs (view_context: untyped) -> String
|
|
6
|
+
def call(view_context:)
|
|
7
|
+
unless mounted?
|
|
8
|
+
raise UnsupportedComponentRendering,
|
|
9
|
+
"reactive components require the SolidObjects::Engine to be mounted in the host routes"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
SolidObjects::Engine.routes.url_helpers.components_path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# @rbs () -> bool
|
|
18
|
+
def mounted?
|
|
19
|
+
return false unless defined?(Rails) && Rails.application
|
|
20
|
+
|
|
21
|
+
Rails.application.routes.routes.any? do |route|
|
|
22
|
+
route.app.respond_to?(:app) &&
|
|
23
|
+
route.app.app.equal?(SolidObjects::Engine)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -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
|