solid_objects 0.14.0 → 0.14.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a64ced341585dcb4b7e9240b2076e1f47a352b4786806c1bec08c10d91c7d187
4
- data.tar.gz: 6d53730c69b2f86cde60d3fdb1ffcefe10dd362dc98652b4afe2b367e1134f32
3
+ metadata.gz: d3b9abc40f188e1ea4964cfffca89e030990948c937a8aca0ec32c19830da272
4
+ data.tar.gz: 57707f08fc468d705202bbc3f20ddd60bfed4cd3d9153c58b7a06c981051ef93
5
5
  SHA512:
6
- metadata.gz: acc3e7a6bb436e0dbadf5a38478fc21d3b072f11fcee0cfed26960cba9c1d8eb790a99cf3b065457de8c2ced86d01718c854635d210ba23ffe571e009eab95f7
7
- data.tar.gz: 91faab1647c20ba461dcf065612b549b2f6dbd955f2a2222f89a2556d377b99b4a648dbb71ba441ca01ba1281607292f3f21300256ad65f55c821374dac85d7e
6
+ metadata.gz: 203477264f47ba941dd4a29b08e00ce5e8ccce298fce361beeaed73f54d3fb1edbbd46f05733e44eaacb366301fc1a6128f084024976268e42ce302d6a7aab76
7
+ data.tar.gz: 1f82eeb81cd282bbf0cdabc3e4346376b21f5688997440a3f6790f3332f2762764d3a3e3a48778629702c0604480e4e0a6380a677d76a106d9f439c466302cf4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1 - 2026-08-24
4
+
5
+ - Register application actors in every process that boots the application.
6
+ The engine now loads the host application's `app/actors` directories from a
7
+ `to_prepare` hook, which previously only the `solid_objects start` process
8
+ did. An actor registers itself as a side effect of its class loading, so a
9
+ lazily loading web process began with an empty registry. `ActorChannel`
10
+ looks the actor up by name, and the resulting `UnknownActorType` reached the
11
+ rescue that rejects the subscription: a Cable subscription for a real actor
12
+ was rejected in any web process that had not yet rendered that actor, and
13
+ the page kept a card that never updated. `ComponentsController` resolves the
14
+ same way through `ActorSnapshot`. `Transmission.receive` already carried a
15
+ registry-miss retry for this reason, and it stays as a guard for a host that
16
+ reaches the gem without the engine.
17
+ - Report why a Cable subscription was rejected. Every reject path in
18
+ `ActorChannel#subscribed` now emits `solid_objects.subscription.rejected`
19
+ with a `reason`, the actor identity, and the `error_class` where an
20
+ exception caused it. Five conditions previously collapsed into one silent
21
+ `reject`, which is invisible from the browser and left nothing in the log to
22
+ distinguish an unregistered actor type from a tampered token. Exception
23
+ messages stay out of the payload, because a component or payload failure can
24
+ carry actor state.
25
+
26
+ - State where `async` waits when no worker runs. The `async` section of the
27
+ README and the runtime section of `docs/operations.md` now say that the
28
+ generator and the migrations start no role, so an application that serves
29
+ web requests alone leaves the message ready until
30
+ `bundle exec solid_objects start` runs the roles. The message is durable
31
+ and waits; it is not lost. `test/integration/background_pickup_test.rb`
32
+ pins it: the message reads `ready` and the actor state stays empty until a
33
+ worker runs. This matches solid-objects-js#22, which reported the same gap
34
+ for `runtime.run(signal)` in the Node package.
35
+
36
+ - Add `examples/at_least_once` and `bundle exec rake at_least_once`, an
37
+ executable proof that the at-least-once clause fires and that the
38
+ documented remedy absorbs it. One actor turn stages an effect that writes
39
+ to an external sink file. The first effect worker crashes between the sink
40
+ write and the acknowledgement, and a second worker reclaims the stale
41
+ effect after the liveness threshold and delivers again. With deduplication
42
+ off the sink reads 2, both deliveries carrying the same `context.id` at
43
+ attempts 1 and 2; with a guard on that id the sink reads 1. The actor state
44
+ commits exactly once in both runs. CI runs the demo in the SQLite job, and
45
+ `docs/correctness.md` links it from the handler idempotency section. This
46
+ mirrors `pnpm run test:at-least-once` in solid-objects-js.
47
+
3
48
  ## 0.14.0 - 2026-08-22
4
49
 
5
50
  - Add `SolidObjects::Transmission.receive(envelope)`, the server ingest for
data/README.md CHANGED
@@ -662,6 +662,12 @@ message = order.async(
662
662
  ).submit
663
663
  ```
664
664
 
665
+ `async` needs a running actor worker. Installing the engine and migrating the
666
+ schema starts no role, so a process that only serves web requests leaves the
667
+ message ready. Nothing is lost. The message waits until
668
+ `bundle exec solid_objects start` runs the roles. See
669
+ [Worker requirements](#worker-requirements) for the feature-by-role table.
670
+
665
671
  Use `available_at:` to spread bulk work or delay one message:
666
672
 
667
673
  ```ruby
@@ -1117,10 +1123,12 @@ and marks process rows stopped on graceful shutdown. A hard-killed worker's
1117
1123
  claimed turn is recovered after its process heartbeat or activation lease
1118
1124
  becomes stale.
1119
1125
 
1120
- Before any role starts, the CLI loads actors from the host application's
1121
- `app/actors` directories through Rails' main autoloader. This works when
1122
- development eager loading is disabled and does not require actor references in
1123
- an initializer.
1126
+ The engine loads actors from the host application's `app/actors` directories
1127
+ through Rails' main autoloader, in every process that boots the application.
1128
+ This works when eager loading is disabled and does not require actor
1129
+ references in an initializer. A web process therefore resolves an actor by
1130
+ name for a Cable subscription or a component render without having loaded that
1131
+ class through an earlier request.
1124
1132
 
1125
1133
  See the [operations guide](docs/operations.md) for monitoring, reconciliation,
1126
1134
  shutdown, retention, and backup guidance.
data/Rakefile CHANGED
@@ -32,6 +32,11 @@ task :steep do
32
32
  sh "bundle exec steep check"
33
33
  end
34
34
 
35
+ desc "Prove the at-least-once clause by crashing an effect worker at a sink"
36
+ task :at_least_once do
37
+ sh "bundle exec ruby examples/at_least_once/demo.rb"
38
+ end
39
+
35
40
  desc "Scan the Rails engine for security warnings"
36
41
  task :security do
37
42
  sh "bundle exec brakeman --force --no-pager -q ."
data/docs/correctness.md CHANGED
@@ -93,6 +93,15 @@ end
93
93
  The guard prevents a repeated state transition. The effect consumer still
94
94
  deduplicates with `context.id`.
95
95
 
96
+ This clause is observable, not decorative. `bundle exec rake at_least_once`
97
+ crashes an effect worker between the external sink write and the
98
+ acknowledgement, restarts one after the liveness threshold, and shows the sink
99
+ reading 2 with deduplication off. Both deliveries carry the same `context.id`
100
+ at attempts 1 and 2. A guard on that id absorbs the same duplicate and the
101
+ sink reads 1. The actor state commits exactly once in both runs. The source is
102
+ `examples/at_least_once/`; solid-objects-js runs the same proof with
103
+ `pnpm run test:at-least-once`.
104
+
96
105
  ## Atomic boundaries
97
106
 
98
107
  The following are atomic:
data/docs/operations.md CHANGED
@@ -31,12 +31,25 @@ Start all configured roles:
31
31
  bundle exec solid_objects start
32
32
  ```
33
33
 
34
- The command loads the host application's `app/actors` directories before
34
+ The generator and the migrations prepare the database and start nothing. A
35
+ process claims ready messages only after this command starts its roles, so an
36
+ application that serves web requests alone leaves every `async` message ready.
37
+ The message is durable and waits for the first process that runs the roles. A
38
+ direct call or an explicit `sync` needs no running role, because the caller's
39
+ own path executes it.
40
+
41
+ The engine loads the host application's `app/actors` directories in every
42
+ process that boots the application, and the command repeats that load before
35
43
  starting any runtime role, even when Rails eager loading is disabled. Actors in
36
44
  the conventional directory do not need initializer references. The targeted
37
45
  loader participates in Rails preparation callbacks so a development reload can
38
46
  replace a registered actor class without loading unrelated application code.
39
47
 
48
+ An actor registers itself as its class loads, and a web process resolves
49
+ actors by name for Cable subscriptions and component renders. Loading them in
50
+ every process is what lets a freshly booted web process serve a live card for
51
+ an actor no request in that process has rendered yet.
52
+
40
53
  Inspect process records and clean stale ownership:
41
54
 
42
55
  ```bash
@@ -187,6 +200,15 @@ transaction rejection, commit-action start/completion/failure, effect and
187
200
  broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
188
201
  retention pruning, process cleanup, and supervisor lifecycle.
189
202
 
203
+ `solid_objects.subscription.rejected` reports a rejected Cable subscription.
204
+ A rejection closes the socket and leaves the page holding a stale card, and
205
+ the browser cannot say which of the conditions applied. The event carries the
206
+ `reason`, the actor identity, and the `error_class` where an exception caused
207
+ it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`,
208
+ `invalid_component_token`, `malformed_component_registration`,
209
+ `missing_subscription_parameter`, or `unauthorized`. Exception messages are
210
+ excluded, because a component or payload failure can carry actor state.
211
+
190
212
  `solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
191
213
  already armed under the same name on the same actor, carrying the actor
192
214
  identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are
data/docs/roadmap.md CHANGED
@@ -126,7 +126,13 @@
126
126
  untested end to end, which is how a raising payload block came to reject the
127
127
  subscription; it is now covered and confined, and the payload authorization
128
128
  context is resolved through `payload_authorization_context` rather than
129
- handing the block a raw Cable connection.
129
+ handing the block a raw Cable connection. Actor registration in a web process
130
+ was assumed rather than arranged: an actor registered only as a side effect
131
+ of its class loading, and only the worker CLI loaded the host's `app/actors`,
132
+ so a lazily loading web process rejected subscriptions for actors it could
133
+ serve until some earlier request happened to load the class. The engine now
134
+ loads them in every process, and a rejected subscription reports which
135
+ condition caused it instead of closing the socket silently.
130
136
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
131
137
  distributed per-actor rate limits and global admission control do not.
132
138
  - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
@@ -0,0 +1,14 @@
1
+ # rbs_inline: enabled
2
+
3
+ class DeliveryCounter < SolidObjects::Actor
4
+ actor_type "delivery-counter"
5
+
6
+ attribute :count, default: 0
7
+
8
+ # @rbs () -> Integer
9
+ def deliver
10
+ self.count += 1
11
+ emit :record
12
+ count
13
+ end
14
+ end
@@ -0,0 +1,47 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "bundler/setup"
4
+ require "active_record"
5
+ require "solid_objects"
6
+
7
+ # Boots a standalone runtime against a shared SQLite file, the way the
8
+ # demo's parent and its crashing children all attach to one database.
9
+ module AtLeastOnceBoot
10
+ ROOT = File.expand_path("../..", __dir__)
11
+
12
+ # @rbs (String database_path) -> void
13
+ def self.call(database_path)
14
+ ActiveRecord::Base.establish_connection(
15
+ adapter: "sqlite3",
16
+ database: database_path,
17
+ pool: 5,
18
+ timeout: 5_000
19
+ )
20
+ ActiveRecord::Migration.verbose = false
21
+ migrate unless ActiveRecord::Base.connection.table_exists?("solid_objects_instances")
22
+
23
+ require "solid_objects/database_adapter"
24
+ %w[
25
+ record process instance message ready_message claimed_message
26
+ reminder effect broadcast dead_letter
27
+ ].each { |model| require File.join(ROOT, "app/models/solid_objects", model) }
28
+
29
+ SolidObjects.configuration.authorize_message = ->(**) { true }
30
+ SolidObjects.configuration.authorize_query = ->(**) { true }
31
+ SolidObjects.configuration.polling_interval = 0.01
32
+ SolidObjects.configuration.process_heartbeat_interval = 0.075
33
+ SolidObjects.configuration.process_alive_threshold = 0.3
34
+ SolidObjects.configuration.lease_duration = 0.25
35
+ SolidObjects.configuration.lease_renewal_interval = 0.05
36
+ end
37
+
38
+ # @rbs () -> void
39
+ def self.migrate
40
+ require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables")
41
+ require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances")
42
+ require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns")
43
+ CreateSolidObjectsTables.new.migrate(:up)
44
+ AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
45
+ RenameMessageDispatchColumns.new.migrate(:up)
46
+ end
47
+ end
@@ -0,0 +1,92 @@
1
+ # rbs_inline: enabled
2
+
3
+ # An executable proof for the at-least-once clause: a contract clause
4
+ # nobody can observe firing is decoration. Run with:
5
+ #
6
+ # bundle exec rake at_least_once
7
+ #
8
+ # Phase one crashes an effect worker between the external sink write and
9
+ # the acknowledgement, restarts one, and shows the sink reading 2 with
10
+ # deduplication off. Both deliveries carry the same stable effect id.
11
+ # Phase two repeats the crash with a guard on that id; the sink reads 1.
12
+ # The actor state commits exactly once in both phases.
13
+
14
+ require_relative "boot"
15
+ require_relative "actor"
16
+ require_relative "sink"
17
+ require "fileutils"
18
+ require "json"
19
+ require "rbconfig"
20
+ require "tmpdir"
21
+
22
+ directory = Dir.mktmpdir("solid_objects_at_least_once_")
23
+ database_path = File.join(directory, "state.sqlite3")
24
+ AtLeastOnceBoot.call(database_path)
25
+
26
+ # @rbs (String message) -> void
27
+ def prove(message)
28
+ raise "proof failed: #{message}" unless yield
29
+ end
30
+
31
+ # @rbs (String actor_id) -> void
32
+ def stage_one_delivery(actor_id)
33
+ DeliveryCounter.ref(actor_id).async.deliver
34
+ worker = SolidObjects::Worker.new
35
+ begin
36
+ worker.run_until_idle
37
+ ensure
38
+ worker.stop
39
+ end
40
+ end
41
+
42
+ # @rbs (database_path: String, sink_path: String, mode: String, deduplication: String) -> Integer?
43
+ def run_effect_worker(database_path:, sink_path:, mode:, deduplication:)
44
+ script = File.expand_path("effect_worker.rb", __dir__)
45
+ pid = Process.spawn(
46
+ RbConfig.ruby, script, database_path, sink_path, mode, deduplication,
47
+ chdir: AtLeastOnceBoot::ROOT
48
+ )
49
+ _pid, status = Process.wait2(pid)
50
+ status.exitstatus
51
+ end
52
+
53
+ # @rbs (database_path: String, sink_path: String, deduplication: String) -> void
54
+ def crash_then_recover(database_path:, sink_path:, deduplication:)
55
+ crash = run_effect_worker(database_path:, sink_path:, mode: "crash", deduplication:)
56
+ prove("the first delivery crashed before acknowledgement") { crash == 1 }
57
+ sleep 0.4
58
+ recovery = run_effect_worker(database_path:, sink_path:, mode: "complete", deduplication:)
59
+ prove("the second delivery completed and acknowledged") { recovery == 0 }
60
+ end
61
+
62
+ begin
63
+ sink_off = File.join(directory, "sink-dedup-off.json")
64
+ stage_one_delivery("dedup-off")
65
+ crash_then_recover(database_path:, sink_path: sink_off, deduplication: "off")
66
+ deliveries = AtLeastOnceSink.read(sink_off)
67
+ effect_ids = deliveries.map { |delivery| delivery.fetch("effect_id") }
68
+ state_off = SolidObjects::Instance.find_by!(actor_id: "dedup-off").state.fetch("count")
69
+ prove("the state commit happened exactly once") { state_off == 1 }
70
+ prove("the sink observed the duplicate") { deliveries.length == 2 }
71
+ prove("both deliveries carried the same stable effect id") { effect_ids.uniq.length == 1 }
72
+
73
+ sink_on = File.join(directory, "sink-dedup-on.json")
74
+ stage_one_delivery("dedup-on")
75
+ crash_then_recover(database_path:, sink_path: sink_on, deduplication: "on")
76
+ guarded = AtLeastOnceSink.read(sink_on)
77
+ state_on = SolidObjects::Instance.find_by!(actor_id: "dedup-on").state.fetch("count")
78
+ prove("the state commit happened exactly once") { state_on == 1 }
79
+ prove("the stable effect id absorbed the duplicate") { guarded.length == 1 }
80
+
81
+ puts JSON.pretty_generate(
82
+ duplicate: {
83
+ state_commits: state_off,
84
+ sink_deliveries: deliveries.length,
85
+ same_effect_id: effect_ids.uniq.length == 1,
86
+ attempts: deliveries.map { |delivery| delivery.fetch("attempt") }
87
+ },
88
+ remedy: { state_commits: state_on, sink_deliveries: guarded.length }
89
+ )
90
+ ensure
91
+ FileUtils.remove_entry(directory) if directory
92
+ end
@@ -0,0 +1,40 @@
1
+ # rbs_inline: enabled
2
+
3
+ require_relative "boot"
4
+ require_relative "actor"
5
+ require_relative "sink"
6
+
7
+ database_path, sink_path, mode, deduplication = ARGV
8
+ raise ArgumentError, "usage: effect_worker.rb DATABASE SINK crash|complete on|off" unless deduplication
9
+
10
+ AtLeastOnceBoot.call(database_path.to_s)
11
+
12
+ SolidObjects.register_effect(:record) do |_arguments, context|
13
+ AtLeastOnceSink.record(
14
+ path: sink_path.to_s,
15
+ effect_id: context.id,
16
+ attempt: context.attempt,
17
+ deduplication: deduplication.to_sym
18
+ )
19
+ # A crash between the external write and the acknowledgement: the sink
20
+ # has the delivery, the effect row never completes.
21
+ Process.exit!(1) if mode == "crash"
22
+ nil
23
+ end
24
+
25
+ # Production runs this on the dead-process-cleanup interval; the demo runs
26
+ # it once, after the liveness threshold, to release the crashed claim.
27
+ SolidObjects::ProcessRegistry.cleanup_dead
28
+
29
+ effect_executor = SolidObjects::EffectExecutor.new
30
+ begin
31
+ worked = false
32
+ 200.times do
33
+ worked = effect_executor.run_once
34
+ break if worked
35
+ sleep 0.01
36
+ end
37
+ raise "no effect became claimable" unless worked
38
+ ensure
39
+ effect_executor.stop
40
+ end
@@ -0,0 +1,27 @@
1
+ # rbs_inline: enabled
2
+
3
+ require "json"
4
+
5
+ # The external system in the at-least-once demo: a JSON file that records
6
+ # every delivery it accepts. With deduplication :off it accepts everything,
7
+ # which makes an at-least-once duplicate visible. With deduplication :on it
8
+ # accepts each stable effect id once, which is the documented remedy.
9
+ module AtLeastOnceSink
10
+ # @rbs (String path) -> Array[Hash[String, untyped]]
11
+ def self.read(path)
12
+ JSON.parse(File.read(path))
13
+ rescue Errno::ENOENT
14
+ []
15
+ end
16
+
17
+ # @rbs (path: String, effect_id: String, attempt: Integer, deduplication: Symbol) -> bool
18
+ def self.record(path:, effect_id:, attempt:, deduplication:)
19
+ deliveries = read(path)
20
+ seen = deliveries.any? { |delivery| delivery.fetch("effect_id") == effect_id }
21
+ return false if deduplication == :on && seen
22
+
23
+ deliveries << { "effect_id" => effect_id, "attempt" => attempt }
24
+ File.write(path, JSON.pretty_generate(deliveries))
25
+ true
26
+ end
27
+ end
@@ -4,6 +4,14 @@ require "action_cable"
4
4
 
5
5
  module SolidObjects
6
6
  class ActorChannel < ActionCable::Channel::Base
7
+ REJECT_REASONS = {
8
+ UnknownActorType => "unregistered_actor_type",
9
+ InvalidStreamToken => "invalid_stream_token",
10
+ InvalidComponentToken => "invalid_component_token",
11
+ JSON::ParserError => "malformed_component_registration",
12
+ KeyError => "missing_subscription_parameter"
13
+ }.freeze
14
+
7
15
  # @rbs () -> void
8
16
  def subscribed
9
17
  identity = StreamToken.verify(params.fetch("token"))
@@ -15,7 +23,9 @@ module SolidObjects
15
23
  actor_id:,
16
24
  authorization_context: connection
17
25
  )
18
- return reject unless authorized
26
+ unless authorized
27
+ return reject_and_report("unauthorized", actor_type:, actor_id:)
28
+ end
19
29
 
20
30
  @reference = Reference.new(actor_type:, actor_id:)
21
31
  @scalar_observables = identity["observables"]
@@ -43,8 +53,8 @@ module SolidObjects
43
53
  JSON::ParserError,
44
54
  InvalidStreamToken,
45
55
  InvalidComponentToken,
46
- UnknownActorType
47
- reject
56
+ UnknownActorType => error
57
+ reject_and_report(reject_reason(error), actor_type:, actor_id:, error:)
48
58
  end
49
59
 
50
60
  private
@@ -54,6 +64,26 @@ module SolidObjects
54
64
  :scalar_observables,
55
65
  :payload_names
56
66
 
67
+ # The exception message stays out of the payload, because a component or
68
+ # payload error can carry actor state into logs.
69
+ # @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
70
+ def reject_and_report(reason, actor_type:, actor_id:, error: nil)
71
+ SolidObjects.instrument(
72
+ :"subscription.rejected",
73
+ reason:,
74
+ actor_type:,
75
+ actor_id:,
76
+ error_class: error&.class&.name
77
+ )
78
+ reject
79
+ end
80
+
81
+ # @rbs (Exception) -> String
82
+ def reject_reason(error)
83
+ match = REJECT_REASONS.find { |error_class, _| error.is_a?(error_class) }
84
+ match ? match.last : "invalid_subscription"
85
+ end
86
+
57
87
  # @rbs (String) -> void
58
88
  def receive_broadcast(stream)
59
89
  invalidation = TurboStreamRenderer.invalidation(stream)
@@ -15,6 +15,10 @@ module SolidObjects
15
15
  SolidObjects::LogSubscriber.install
16
16
  end
17
17
 
18
+ initializer "solid_objects.actors" do |application|
19
+ application.config.to_prepare { ApplicationActorLoader.new.call }
20
+ end
21
+
18
22
  initializer "solid_objects.database", after: :load_config_initializers do
19
23
  ActiveSupport.on_load(:active_record) do
20
24
  require RECORD_PATH
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.14.0"
4
+ VERSION = "0.14.1"
5
5
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class ActorChannel < ActionCable::Channel::Base
5
+ REJECT_REASONS: untyped
6
+
5
7
  # @rbs () -> void
6
8
  def subscribed: () -> void
7
9
 
@@ -15,6 +17,14 @@ module SolidObjects
15
17
 
16
18
  attr_reader payload_names: untyped
17
19
 
20
+ # The exception message stays out of the payload, because a component or
21
+ # payload error can carry actor state into logs.
22
+ # @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
23
+ def reject_and_report: (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
24
+
25
+ # @rbs (Exception) -> String
26
+ def reject_reason: (Exception) -> String
27
+
18
28
  # @rbs (String) -> void
19
29
  def receive_broadcast: (String) -> void
20
30
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-23 00:00:00.000000000 Z
11
+ date: 2026-08-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -371,6 +371,11 @@ files:
371
371
  - examples/application/app/views/chat_rooms/show.html.erb
372
372
  - examples/application/config/initializers/solid_objects.rb
373
373
  - examples/application/config/routes.rb
374
+ - examples/at_least_once/actor.rb
375
+ - examples/at_least_once/boot.rb
376
+ - examples/at_least_once/demo.rb
377
+ - examples/at_least_once/effect_worker.rb
378
+ - examples/at_least_once/sink.rb
374
379
  - exe/solid_objects
375
380
  - lib/generators/solid_objects/install_generator.rb
376
381
  - lib/generators/solid_objects/templates/solid_objects.rb