Skip to content

Commit d9667bd

Browse files
committed
fix: Detect replaced work in projections
Compare complete staged work around projections so draining one intent and staging another cannot bypass the read-only contract. Cover effect and commit-action replacement with terminal rollback regressions.
1 parent 4530819 commit d9667bd

4 files changed

Lines changed: 37 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
- Reject query and observable state mutation and staged durable work with
66
terminal `QueryMutatedState` errors. Cover individual snapshot projections and
7-
preserve ordinary operations' already-staged work while reading projections.
7+
preserve ordinary operations' already-staged work while reading projections,
8+
including replacements that leave the intent count unchanged.
89
- Pin reserved JSON property names with shared Ruby/JS fixtures. Document the
910
reminder-name limit difference and the authorized dead-transmit retry API.
1011

‎lib/solid_objects/actor.rb‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -556,15 +556,22 @@ def intent_count
556556
# @rbs (String) { () -> untyped } -> untyped
557557
def read_projection(operation)
558558
state_before = state.to_h
559-
intents_before = intent_count
559+
intents_before = intent_snapshot
560560
result = guard_application_writes(operation) { yield }
561-
unless state.to_h == state_before && intent_count == intents_before
561+
unless state.to_h == state_before && intent_snapshot == intents_before
562562
raise QueryMutatedState, "observables must not mutate actor state or stage durable work"
563563
end
564564

565565
result
566566
end
567567

568+
# @rbs () -> Array[Array[Hash[Symbol, untyped]]]
569+
def intent_snapshot
570+
[ effect_intents, effect_recovery_intents, commit_action_intents, reminder_intents, outbound_message_intents ].map do |intents|
571+
intents.map { |intent| intent.to_h.deep_dup }
572+
end
573+
end
574+
568575
# @rbs (String) { () -> untyped } -> untyped
569576
def guard_application_writes(operation, &block)
570577
ApplicationWriteGuard.call(

‎sig/generated/lib/solid_objects/actor.rbs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,9 @@ module SolidObjects
366366
# @rbs (String) { () -> untyped } -> untyped
367367
def read_projection: (String) { () -> untyped } -> untyped
368368

369+
# @rbs () -> Array[Array[Hash[Symbol, untyped]]]
370+
def intent_snapshot: () -> Array[Array[Hash[Symbol, untyped]]]
371+
369372
# @rbs (String) { () -> untyped } -> untyped
370373
def guard_application_writes: (String) { () -> untyped } -> untyped
371374

‎test/integration/read_only_actor_test.rb‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ def append
2626
items
2727
end
2828

29+
def append_with_work(action:)
30+
perform_action(action)
31+
append
32+
end
33+
2934
private
3035

3136
def perform_action(action)
@@ -36,6 +41,12 @@ def perform_action(action)
3641
when "reminder" then schedule(at: Time.now + 60).append
3742
when "outbound" then send_to(self.class.ref("other")).append
3843
when "state" then items << "unexpected"
44+
when "replace_effect", "replace_commit_action"
45+
return unless intent_count.positive?
46+
47+
discard_intents
48+
emit(:replacement) if action == "replace_effect"
49+
commit_action(:replacement) if action == "replace_commit_action"
3950
end
4051
end
4152
end
@@ -45,6 +56,7 @@ def perform_action(action)
4556
SolidObjects.configuration.max_attempts = 3
4657
SolidObjects.configuration.retry_delay = ->(_) { 0 }
4758
SolidObjects.register_commit_action(:unexpected) { SolidObjectsTestDomainRecord.create!(name: "unexpected") }
59+
SolidObjects.register_commit_action(:replacement) { SolidObjectsTestDomainRecord.create!(name: "replacement") }
4860
end
4961

5062
%w[effect recovery commit_action reminder outbound state].each do |action|
@@ -75,6 +87,17 @@ def perform_action(action)
7587
assert_empty SolidObjects::Instance.all
7688
end
7789

90+
%w[effect commit_action].each do |action|
91+
test "observables cannot replace staged #{action} with the same intent count" do
92+
Reader.projection_action = "replace_#{action}"
93+
94+
error = assert_raises(SolidObjects::MessageFailed) { Reader.ref("one").sync.append_with_work(action:) }
95+
96+
assert_equal "SolidObjects::QueryMutatedState", error.details.fetch("class")
97+
assert_no_committed_work
98+
end
99+
end
100+
78101
test "pure projections preserve effects already staged by an operation" do
79102
reader = Reader.new(actor_id: "local", state: SolidObjects::State.new(Reader.definition.state_definition))
80103
reader.emit(:expected)

0 commit comments

Comments
 (0)