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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +10 -0
  3. data/README.md +134 -25
  4. data/app/controllers/solid_objects/components_controller.rb +78 -0
  5. data/app/helpers/solid_objects/actor_helper.rb +13 -4
  6. data/config/routes.rb +1 -0
  7. data/db/migrate/20260806000000_add_state_revision_to_solid_objects_instances.rb +12 -0
  8. data/docs/architecture.md +61 -15
  9. data/docs/authorization.md +38 -1
  10. data/docs/correctness.md +28 -2
  11. data/docs/database-schema.md +13 -5
  12. data/docs/realtime.md +106 -12
  13. data/docs/roadmap.md +6 -5
  14. data/examples/application/README.md +5 -4
  15. data/examples/application/app/views/actors/chat_room_actor/_messages.html.erb +1 -1
  16. data/examples/application/app/views/chat_rooms/show.html.erb +2 -2
  17. data/examples/application/config/initializers/solid_objects.rb +9 -3
  18. data/lib/generators/solid_objects/templates/solid_objects.rb +3 -0
  19. data/lib/solid_objects/actor.rb +11 -0
  20. data/lib/solid_objects/actor_channel.rb +73 -5
  21. data/lib/solid_objects/actor_snapshot.rb +25 -6
  22. data/lib/solid_objects/actor_view.rb +103 -10
  23. data/lib/solid_objects/component_path_resolver.rb +27 -0
  24. data/lib/solid_objects/component_registration.rb +117 -0
  25. data/lib/solid_objects/component_renderer.rb +82 -0
  26. data/lib/solid_objects/component_subscriptions.rb +109 -0
  27. data/lib/solid_objects/component_token.rb +139 -0
  28. data/lib/solid_objects/component_view.rb +67 -0
  29. data/lib/solid_objects/configuration.rb +12 -0
  30. data/lib/solid_objects/database_adapters/sqlite.rb +15 -3
  31. data/lib/solid_objects/errors.rb +12 -0
  32. data/lib/solid_objects/executor.rb +1 -0
  33. data/lib/solid_objects/stream_token.rb +32 -13
  34. data/lib/solid_objects/turbo_stream_renderer.rb +45 -1
  35. data/lib/solid_objects/version.rb +1 -1
  36. data/lib/solid_objects.rb +6 -0
  37. data/sig/generated/controllers/solid_objects/components_controller.rbs +22 -0
  38. data/sig/generated/lib/solid_objects/actor.rbs +3 -0
  39. data/sig/generated/lib/solid_objects/actor_channel.rbs +20 -0
  40. data/sig/generated/lib/solid_objects/actor_snapshot.rbs +17 -0
  41. data/sig/generated/lib/solid_objects/actor_view.rbs +31 -2
  42. data/sig/generated/lib/solid_objects/component_path_resolver.rbs +13 -0
  43. data/sig/generated/lib/solid_objects/component_registration.rbs +51 -0
  44. data/sig/generated/lib/solid_objects/component_renderer.rbs +39 -0
  45. data/sig/generated/lib/solid_objects/component_subscriptions.rbs +40 -0
  46. data/sig/generated/lib/solid_objects/component_token.rbs +38 -0
  47. data/sig/generated/lib/solid_objects/component_view.rbs +43 -0
  48. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  49. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
  50. data/sig/generated/lib/solid_objects/errors.rbs +12 -0
  51. data/sig/generated/lib/solid_objects/stream_token.rbs +9 -4
  52. data/sig/generated/lib/solid_objects/turbo_stream_renderer.rbs +10 -0
  53. 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
@@ -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
 
@@ -98,6 +98,7 @@ module SolidObjects
98
98
  instance.update!(
99
99
  state: serialized_state,
100
100
  state_version: actor.class.state_version,
101
+ state_revision: locked_message.sequence,
101
102
  last_used_at: SolidObjects.database_adapter.database_now
102
103
  )
103
104
  locked_message.update!(
@@ -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
- verifier.generate(
14
- {
15
- "actor_type" => reference.actor_type,
16
- "actor_id" => reference.actor_id
17
- },
18
- purpose: PURPOSE
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, 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
- rescue ActiveSupport::MessageVerifier::InvalidSignature
33
- raise InvalidStreamToken, "invalid actor stream token"
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