statesman-solid_objects 0.1.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.
data/docs/findings.md ADDED
@@ -0,0 +1,197 @@
1
+ # Findings: what actually needs to change?
2
+
3
+ Historical investigation from September 11. Core 0.14.6 subsequently fixed the
4
+ post-commit bug, and the adapter now uses published dependencies without the
5
+ extra transition column. See [current compatibility](compatibility.md) and
6
+ [current verification](verification.md); the counts and limitations below
7
+ describe the original prototype, not the current release candidate.
8
+
9
+ Measured on September 11, 2026. **Existing primitives support a useful
10
+ Statesman integration. A caller-bound execution API and synchronous joining
11
+ of application transactions are not prerequisites.**
12
+
13
+ This corrects the earlier observer-transport conclusion. Original Ruby object
14
+ identity cannot cross a durable JSON message boundary, but registered machine
15
+ classes and persisted parent records can reconstruct the required behavior.
16
+ That distinction is the basis of this prototype.
17
+
18
+ ## Concurrency result
19
+
20
+ Six threads hold separate Active Record connections. A queue barrier stops
21
+ them after they have read the original state and entered caller-side guard
22
+ validation, then releases all six. These are real Statesman calls, not mocked
23
+ adapter calls or sequential requests.
24
+
25
+ | Scenario | Successes | Other outcomes |
26
+ | --- | ---: | --- |
27
+ | Prototype, bang single-use transition | 1 | 5 `TransitionFailedError` |
28
+ | Prototype, non-bang single-use transition | 1 | 5 `false` |
29
+ | Prototype, guarded self-transition | 1 | 5 `GuardFailedError` |
30
+ | Stock Active Record adapter, same single-use race | 1 | 5 `TransitionConflictError` |
31
+
32
+ The guarded self-transition consumes one persisted unit. It demonstrates why
33
+ checking only the source state would be insufficient.
34
+
35
+ The actor stages a commit action, and that action reconstructs the machine
36
+ and repeats validation before writing. Statesman also validates before
37
+ calling the storage adapter, so merely serializing adapter inserts would
38
+ leave stale caller decisions intact. See upstream
39
+ [`Machine#transition_to!`](https://github.com/gocardless/statesman/blob/3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8/lib/statesman/machine.rb)
40
+ and the [Active Record adapter](https://github.com/gocardless/statesman/blob/3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8/lib/statesman/adapters/active_record.rb).
41
+
42
+ Negative control: temporarily replacing the worker's `transition_to!` call
43
+ with a direct persistence call made **both concurrency tests fail with six
44
+ successes**. The authoritative validation was then restored. This test proves
45
+ the particular concurrency guarantee, not universal absence of SQL errors or
46
+ conflicts when application code bypasses the actor.
47
+
48
+ ## Restrictions that matter
49
+
50
+ | Capability or restriction | Observed behavior | Practical significance |
51
+ | --- | --- | --- |
52
+ | Registered machine reconstruction | Works in fresh actor/effect processes booted before enqueue | Existing primitives suffice; no per-request closure registry |
53
+ | Before/after database callbacks | Run around the row write inside the fenced transaction; failures roll everything back | Suitable for bounded same-pool database work |
54
+ | Inline after-commit timing | Runs after commit; transaction depth is zero | Existing Rails callback registration is enough for timing |
55
+ | Inline after-commit error | Originally became `LostActivation` despite successful persistence; the local core fix exposes the original `RuntimeError` | Persistence remains committed; report the callback failure in the executing process |
56
+ | Emitted callbacks | Deferred, retryable, can duplicate and observe later history | Use for idempotent eventual work, not identical inline semantics |
57
+ | Application transactions | Async enqueue commits/rolls back with application writes; sync rejects before enqueue | Existing async API solves atomic acceptance, not atomic execution |
58
+ | Original observer and unsaved parent | Worker uses new objects and persisted attributes | Material if callbacks depend on instance identity or unsaved data |
59
+ | Request-local `Current` | Visible with caller assistance, absent in a fresh worker | Pass explicit JSON data or persist context; local success can mislead |
60
+ | Guard evaluation | Caller and worker both evaluate guards | Guards must be pure/repeatable; worker's persisted view is authoritative |
61
+ | Metadata | Symbol keys become strings through JSON | Normalize at the adapter/app boundary; not a reason for arbitrary Ruby serialization |
62
+ | Callback errors before commit | Surface as `SolidObjects::MessageFailed` with original class in details | Exception identity changes; application retry handling needs review |
63
+ | Guard error details | Error retains caller parent but not original guard callback object | Class-level compatibility, not full exception-object identity |
64
+ | Connection pools | Registration rejects a different pool before enqueue | Same URL through separate pools is not sufficient |
65
+ | Nested callback transitions | Emitted callback cannot reuse the commit-only write path | Requires a new authorized actor message; nesting is not drop-in |
66
+ | History and state scopes | Sorted rows, +10 keys, most-recent flags and `in_state` work after restart | Preserve the useful Rails query surface |
67
+
68
+ An async command that fails later does **not** roll back application writes
69
+ that already committed with its enqueue. Likewise, effects cannot make remote
70
+ side effects part of a SQL rollback. These are different product contracts,
71
+ not missing event APIs.
72
+
73
+ The callback retry test deliberately writes an event and then raises: the
74
+ event is written twice across two effect attempts, while the transition row
75
+ is written once. This is a deterministic demonstration of duplicate delivery,
76
+ not a process-crash experiment.
77
+
78
+ ## The demonstrated core bug
79
+
80
+ The original inline callback characterization raised
81
+ `RuntimeError, "failed after_commit"` after SQL commit. Unfixed core then did
82
+ the following:
83
+
84
+ 1. `Executor#complete` has already saved actor state, transition rows, and
85
+ message completion, and deleted the claimed-message row.
86
+ 2. The Rails after-commit callback raises while the transaction method returns.
87
+ 3. `Executor#call` catches the error as though the actor turn had failed,
88
+ restores its old in-memory snapshot, and calls `fail_message`.
89
+ 4. `matching_claim!` cannot find the already-deleted claim and raises
90
+ `LostActivation, "message claim changed"`.
91
+
92
+ The test observes a completed message with no message error, the committed
93
+ transition, no dead letter, and a successful subsequent transition. It does
94
+ **not** demonstrate durable-state corruption. It does demonstrate misleading
95
+ failure classification and lost top-level callback diagnostics.
96
+
97
+ Reproduction in the prototype:
98
+
99
+ ```sh
100
+ mise exec ruby@3.3.9 -- ruby -rbundler/setup -Itest test/prototype_test.rb \
101
+ --include /inline_after.commit_exception/
102
+ ```
103
+
104
+ The local core fix was developed against an independent failing Minitest
105
+ regression using Active Record callbacks without Statesman. The prototype
106
+ test now expects the original `RuntimeError, "failed after_commit"`, retaining
107
+ its persistence and subsequent-transition assertions. A separate waiting
108
+ caller may receive the completed result before a worker callback fails; the
109
+ error is exposed in the executing process without rewriting message history.
110
+ The [fresh-session prompt](core-follow-up-prompt.md) records the original scope.
111
+
112
+ ## What is not claimed
113
+
114
+ - This does not pass the full upstream adapter contract. Upstream
115
+ [shared examples](https://github.com/gocardless/statesman/blob/3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8/spec/statesman/adapters/shared_examples.rb)
116
+ expect the supplied observer itself to receive callbacks and permit repeated
117
+ raw `create(:x, :y)` calls. This prototype reconstructs the machine and
118
+ validates commands. It exposes all six requested methods but does not run
119
+ those RSpec examples unchanged.
120
+ - Initial transition creation, arbitrary custom machine constructors,
121
+ failure-callback cardinality/side effects, nested before/after transitions,
122
+ non-null/partial-index schema variants, multiple Rails versions, composite
123
+ keys, and sharding are not certified.
124
+ - Stock transition tables do not necessarily contain the prototype's extra
125
+ `from_state` column. Removing that schema requirement or shipping a migration
126
+ is adapter release work. Boot registration validation and a public factory/
127
+ async helper also need release polish.
128
+ - The code assumes one actor owns every transition writer for a parent.
129
+ Guarded application data changed by other writers still needs appropriate
130
+ application-level coordination.
131
+ - Fresh-process tests use real `SolidObjects::Worker` and `EffectExecutor`
132
+ instances. They do not exercise the supervisor CLI, host Rails autoloading,
133
+ abrupt worker death, stale-lease takeover, deployment upgrades, production
134
+ throughput, or multi-tenant revocation after enqueue.
135
+ - Steep checks the library with the same lenient diagnostic policy used by
136
+ core and shared framework signatures. Numerous Rails/Statesman boundaries
137
+ remain `untyped`; a green type check is not complete static verification.
138
+ - No gem publication, release, deployment, or upstream PR. The subsequent core
139
+ fix and JS parity documentation remain local and uncommitted.
140
+
141
+ ## Verification
142
+
143
+ The original characterization matrix, before the core fix:
144
+
145
+ | Database | Tests | Assertions | Failures / errors / skips | Elapsed |
146
+ | --- | ---: | ---: | --- | ---: |
147
+ | SQLite | 28 | 136 | 0 / 0 / 0 | 3.21 s |
148
+ | PostgreSQL 18 container | 28 | 136 | 0 / 0 / 0 | 6.03 s |
149
+ | MySQL 8.4 container | 28 | 136 | 0 / 0 / 0 | 6.32 s |
150
+
151
+ These are single final matrix runs, not a load benchmark. The SQLite default
152
+ task additionally passed Standard, RuboCop (13 files), RBS generation/
153
+ validation, and Steep. RBS reports zero generated files when existing
154
+ signatures already match its output.
155
+
156
+ Runtime: Ruby 3.3.9, Active Record 8.1.3.1, Solid Objects 0.14.5 at
157
+ `e7b7ab7634cd2980de277fcfa9217d93c9c3aa52`, and Statesman 13.3.0 at
158
+ `3a7e9b7a69a6d627e8e76e84e5c59cd0827755c8`. Dependencies are pinned in
159
+ `Gemfile.lock`. Commands are in the [README](../README.md).
160
+
161
+ The disposable database containers were removed after verification. To create
162
+ equivalent local-only test services again (no production credentials):
163
+
164
+ ```sh
165
+ docker run --detach --name statesman-prototype-pg-20260911 \
166
+ --publish 127.0.0.1:55439:5432 \
167
+ --env POSTGRES_HOST_AUTH_METHOD=trust --env POSTGRES_DB=statesman_prototype \
168
+ postgres:18-alpine
169
+ docker run --detach --name statesman-prototype-mysql-20260911 \
170
+ --publish 127.0.0.1:53309:3306 \
171
+ --env MYSQL_ALLOW_EMPTY_PASSWORD=yes --env MYSQL_DATABASE=statesman_prototype \
172
+ mysql:8.4
173
+ ```
174
+
175
+ Wait for each database to finish initialization before running its suite.
176
+ The connection pool is explicitly 20 because six callers wait at a barrier
177
+ while holding separate connections; the default pool of five cannot satisfy
178
+ that test's setup. These unauthenticated containers are disposable local
179
+ fixtures, not deployment recommendations.
180
+
181
+ The September 12 core-fix follow-up reran the prototype against the locally
182
+ edited core on Ruby 3.3.9 and Active Record 8.1.3.1: SQLite, PostgreSQL 18,
183
+ and MySQL 8.4 each passed 28 tests and 136 assertions with no failures, errors,
184
+ or skips. The SQLite default pipeline also passed Standard, RuboCop, RBS, and
185
+ Steep. The inline callback test now asserts the original `RuntimeError`;
186
+ its committed-history and subsequent-transition assertions are unchanged.
187
+
188
+ TDD began with the normal-transition test failing on the unimplemented
189
+ adapter. Subsequent red/green tests drove effect mode, connection-pool
190
+ validation, shared association counters, authorized callback reads, and the
191
+ nested-write boundary. The deliberate validation-removal experiment supplied
192
+ the final negative control.
193
+
194
+ The older [observer-boundary probe](../experiments/observer_boundary_probe.rb)
195
+ remains historical evidence of what cannot be transported, not a release gate
196
+ or proof that reconstruction is impossible. It is excluded from the default
197
+ suite and depends on core's internal test harness.
@@ -0,0 +1,47 @@
1
+ # Existing-primitives experiment
2
+
3
+ The earlier observer transport probes tested a stronger compatibility goal
4
+ than a useful Statesman integration necessarily needs. This experiment keeps
5
+ Solid Objects core unchanged and tests registered, reconstructible machines.
6
+
7
+ ## Decisions before implementation
8
+
9
+ 1. Keep the existing Active Record transitions table. A boot-registered commit
10
+ action reloads the parent and machine, performs authoritative Statesman
11
+ validation, runs before/after callbacks, and writes the transition.
12
+ 2. Read history directly from the table, ordered by sort key. Authorize public
13
+ reads; reads inside an already-authorized matching commit action use its
14
+ narrowly scoped execution context.
15
+ 3. Keep `most_recent`. Clear the previous row and insert the next row in the
16
+ same transaction, using the column's nullability for old-row values.
17
+ 4. Keep +10 sort keys in actor state, seeded from existing table history.
18
+ Rejected commit actions must roll back the actor counter and table writes.
19
+ 5. Compare two callback modes: normal transaction-record after-commit callbacks,
20
+ and delayed callbacks reconstructed from an emitted effect. Measure failures
21
+ and duplicate delivery rather than assuming they are interchangeable.
22
+ 6. Use a JSON tuple of parent base-class name and scalar primary key as actor
23
+ identity, with separate counters per transition association inside that actor.
24
+ 7. Keep default-deny public policies. Pass an authenticated principal to public
25
+ calls and bind policy checks to actor identity and operation. Registrations
26
+ exist at boot in every process; no request-specific closures are transported.
27
+
28
+ ## Behavioral experiment list
29
+
30
+ - A normal transition persists and runs callbacks around the table write.
31
+ - Concurrent attempts at a single-use transition have one winner; compare the
32
+ same race with Statesman's Active Record adapter.
33
+ - Guarded self-transitions recheck persisted conditions in the serialized turn.
34
+ - History, query scopes, and sort keys survive fresh-process execution.
35
+ - A boot-registered machine works in a separate worker without caller objects.
36
+ - Unsaved parent attributes, request-local context, and observer instance state
37
+ reveal the actual reconstruction restrictions.
38
+ - Callback database writes roll back with rejected or failed turns.
39
+ - Inline after-commit failure is observed after durable commit.
40
+ - Emitted callbacks survive restart and retry independently of transition state.
41
+ - Existing application transactions reject sync; async enqueue commits or
42
+ rolls back with the surrounding application transaction and runs afterward.
43
+ - Authorization and mismatched database pools fail clearly.
44
+
45
+ The final findings and revised core prompt will distinguish necessary fixes,
46
+ optional compatibility work, and behavior already supported. This is a
47
+ prototype, not a published 0.1.0 gem or a claim of complete adapter parity.
@@ -0,0 +1,21 @@
1
+ # 0.1.0 continuation
2
+
3
+ The 0.14.6 core release removes the demonstrated post-commit blocker. Keep
4
+ core and JS untouched and continue the registered-machine design, using the
5
+ published gems by default rather than a sibling checkout.
6
+
7
+ TDD scenarios, in order:
8
+
9
+ 1. The standalone package declares its name, version, and released runtime dependencies.
10
+ 2. Inline and emitted callbacks work with the stock transition schema, without `from_state`.
11
+ 3. Public registration builds an authorized machine; missing/mismatched registration and parents fail clearly.
12
+ 4. Initial transitions persist once and continue normal +10 history.
13
+ 5. Callback nesting cannot reuse an already-consumed transition write.
14
+ 6. Adapter contract behavior, including metadata and history reloads, has explicit tests and documented differences from upstream shared examples.
15
+ 7. Installation leaves authorization denied unless the application provides policies.
16
+ 8. The gem loads outside this workspace and the complete suite passes against the released core on all three databases.
17
+
18
+ Reconstruction still does not preserve the original observer, unsaved parent
19
+ attributes, request-local state, or ambient synchronous transactions. Those
20
+ are documented restrictions, not new core feature requests. Do not publish or
21
+ claim full drop-in compatibility during this continuation.
data/docs/releases.md ADDED
@@ -0,0 +1,67 @@
1
+ # Tag releases
2
+
3
+ GitHub Actions runs `.github/workflows/ci.yml` on pushes, pull requests, and
4
+ manual requests. SQLite runs the full Minitest, Standard, RuboCop, RBS, and
5
+ Steep pipeline plus a gem build. PostgreSQL 18 and MySQL 8.4 run the tests
6
+ against disposable service databases.
7
+
8
+ Only a `v*` tag pushed to `cardmagic/statesman-solid_objects` can run the
9
+ release job. That job waits for all three checks, verifies that the tag
10
+ equals `v` plus `Statesman::SolidObjects::VERSION`, and builds the gem before
11
+ requesting publishing credentials. It pushes the exact built artifact to
12
+ RubyGems.org and creates a GitHub Release with that gem attached.
13
+
14
+ Ordinary pushes, pull requests, fork workflows, and manual workflow requests
15
+ do not publish. The release job alone has `id-token: write` and
16
+ `contents: write`; test jobs have read-only repository access. Actions are
17
+ pinned to commit SHAs, and checkouts do not retain Git credentials.
18
+
19
+ ## Trusted publisher
20
+
21
+ RubyGems uses the following identity:
22
+
23
+ | Setting | Value |
24
+ | --- | --- |
25
+ | Gem | `statesman-solid_objects` |
26
+ | Repository owner | `cardmagic` |
27
+ | Repository name | `statesman-solid_objects` |
28
+ | Workflow filename | `ci.yml` |
29
+ | Environment | `release` |
30
+
31
+ The GitHub `release` environment permits only tags matching `v*`, with no
32
+ manual approval required. RubyGems trusts that environment and workflow via
33
+ OIDC; no long-lived RubyGems API key is stored in GitHub.
34
+
35
+ Before the first release, configure a
36
+ [pending trusted publisher](https://rubygems.org/profile/oidc/pending_trusted_publishers)
37
+ with the values above. Leave the reusable-workflow repository fields blank.
38
+ The pending publisher created during setup was valid for about 12 hours.
39
+ Check its expiration before the first tag and recreate it if necessary.
40
+ The first successful push converts it into the gem's regular trusted
41
+ publisher. Subsequent releases use the same workflow without repeating setup.
42
+ See the [RubyGems trusted-publishing guide](https://guides.rubygems.org/trusted-publishing/).
43
+
44
+ ## Publish a version
45
+
46
+ 1. Update `lib/statesman/solid_objects/version.rb`, date the matching
47
+ `CHANGELOG.md` entry, and update the README's release status. Regenerate
48
+ `Gemfile.lock` with Bundler and update version-specific package tests.
49
+ 2. Run `bundle exec rake` and `bundle exec rake build`.
50
+ 3. Commit and push `main`; wait for all CI checks to pass.
51
+ 4. Push an annotated tag matching the version. For the first release:
52
+
53
+ ```sh
54
+ git tag -a v0.1.0 -m "Version 0.1.0"
55
+ git push origin v0.1.0
56
+ ```
57
+
58
+ 5. Watch that tag's CI run. Verify the RubyGems version, GitHub Release, and
59
+ attached artifact before announcing publication.
60
+
61
+ Configuring this pipeline does not publish a gem. Its first OIDC exchange
62
+ and upload can only be validated end to end by an actual release.
63
+
64
+ If the RubyGems upload succeeds but GitHub Release creation fails, the gem is
65
+ already published. Check both services before retrying; RubyGems versions
66
+ cannot be overwritten. Finish the missing GitHub Release instead of pushing
67
+ the same gem again.
@@ -0,0 +1,103 @@
1
+ # Verification
2
+
3
+ September 12, 2026 continuation, after Solid Objects 0.14.6 was published.
4
+
5
+ The dependency lockfile now resolves released `solid_objects 0.14.6` and
6
+ `statesman 13.3.0` from RubyGems. No sibling core checkout is required for
7
+ runtime, tests, RBS, or Steep. Ruby is 3.3.9 and Active Record is 8.1.3.1.
8
+
9
+ ## Database evidence
10
+
11
+ The final 41-scenario run passed with 217 assertions and zero failures, errors, or
12
+ skips on each database:
13
+
14
+ | Database | Elapsed |
15
+ | --- | ---: |
16
+ | SQLite | 3.94 s |
17
+ | PostgreSQL 18 | 7.04 s |
18
+ | MySQL 8.4 | 7.66 s |
19
+
20
+ The SQLite default task also passed Standard, RuboCop (17 files), RBS
21
+ generation/validation, and Steep. Steep uses the installed core's signatures
22
+ and its lenient diagnostic policy; Rails/Statesman boundaries still include
23
+ untyped values.
24
+
25
+ A package test included in that matrix builds the gem, installs it into a temporary gem
26
+ home, and starts an unbundled Ruby process outside the workspace. It verifies
27
+ that the installed adapter and released core load, without the repository's
28
+ load path. The standalone `rake build` task also produced the local 0.1.0 artifact.
29
+ The README authorization example was executed against the real adapter for
30
+ both initial and ordinary transitions, including keyword-argument normalization.
31
+
32
+ These are integration checks, not throughput measurements or a complete Rails
33
+ version matrix. See [compatibility](compatibility.md) for remaining boundaries.
34
+
35
+ ## TDD evidence
36
+
37
+ Observed red/green failures during this continuation:
38
+
39
+ - Missing standalone gemspec -> valid package name/version/dependencies.
40
+ - Missing default require entrypoint -> successful gem loading.
41
+ - Stock schema -> MessageFailed while the old adapter tried writing from_state;
42
+ removing that write and putting the source state in effect arguments fixed both modes.
43
+ - Public factory keyword rejected -> authorized registered-machine construction.
44
+ - Invalid callback mode accepted -> explicit configuration validation.
45
+ - Missing registration silently defaulted to orders -> required matching registration.
46
+ - Initial transition rejected as pending-to-pending -> distinct idempotent initialization command.
47
+ - Recursive before callback raised SystemStackError -> clear re-entry error and rollback.
48
+ - Application post-commit rejection became KeyError -> preserve unrecognized rejection codes.
49
+
50
+ The race negative control was repeated against the released dependencies:
51
+ temporarily replacing the worker's transition_to! with direct persistence made
52
+ both the single-use and guarded-self-transition tests fail with six successes.
53
+ Restoring validation made both pass again. The continuing suite also retains
54
+ the same stock Active Record comparison.
55
+
56
+ ## Reproduce
57
+
58
+ With the correct Ruby environment:
59
+
60
+ ```sh
61
+ bundle install
62
+ bundle exec rake
63
+ bundle exec rake build
64
+ ```
65
+
66
+ On this workstation, the Homebrew bundle launcher can select the wrong Ruby.
67
+ The verified interpreter-pinned equivalent is:
68
+
69
+ ```sh
70
+ mise exec ruby@3.3.9 -- ruby -S bundle install
71
+ mise exec ruby@3.3.9 -- ruby -rbundler/setup -S rake
72
+ ```
73
+
74
+ Create disposable loopback-only databases, never application databases:
75
+
76
+ ```sh
77
+ docker run --detach --name statesman-driver-pg-20260912 \
78
+ --publish 127.0.0.1:55439:5432 \
79
+ --env POSTGRES_HOST_AUTH_METHOD=trust --env POSTGRES_DB=statesman_driver \
80
+ postgres:18-alpine
81
+ docker run --detach --name statesman-driver-mysql-20260912 \
82
+ --publish 127.0.0.1:53309:3306 \
83
+ --env MYSQL_ALLOW_EMPTY_PASSWORD=yes --env MYSQL_DATABASE=statesman_driver \
84
+ mysql:8.4
85
+ ```
86
+
87
+ Wait for initialization, then run:
88
+
89
+ ```sh
90
+ SOLID_OBJECTS_DATABASE_URL=postgresql://postgres@127.0.0.1:55439/statesman_driver \
91
+ mise exec ruby@3.3.9 -- ruby -rbundler/setup -S rake test
92
+ SOLID_OBJECTS_DATABASE_URL=mysql2://root@127.0.0.1:53309/statesman_driver \
93
+ mise exec ruby@3.3.9 -- ruby -rbundler/setup -S rake test
94
+ ```
95
+
96
+ External test databases must be empty before each run. The harness creates
97
+ the schema and resets its rows. It uses a pool of 20 because six racing callers
98
+ hold connections at a barrier. SQLite creates and removes its own temporary
99
+ database. Docker database fixtures can be removed and recreated after use.
100
+
101
+ At the end of this validation run, the repository was a local, unpublished
102
+ candidate. No core changes, JavaScript changes, remote repository creation,
103
+ commits, pushes, or releases were performed during that validation.
@@ -0,0 +1,55 @@
1
+ # rbs_inline: enabled
2
+
3
+ module Statesman
4
+ module SolidObjects
5
+ class Actor < ::SolidObjects::Actor
6
+ actor_type "statesman-transition"
7
+
8
+ attribute :sort_keys, default: -> { {} }
9
+
10
+ # @rbs (registration: String, parent_id: String, to: String, metadata: Hash[String, untyped]) -> Integer
11
+ def transition(registration:, parent_id:, to:, metadata:)
12
+ definition = SolidObjects.registrations.fetch(registration)
13
+ parent = parent_for(definition, parent_id)
14
+ from = definition.history(parent).last&.to_state || definition.machine_class.initial_state
15
+ stage_transition(definition, parent, to, metadata, from:, operation: "transition")
16
+ end
17
+
18
+ # @rbs (registration: String, parent_id: String) -> Integer
19
+ def initialize_state(registration:, parent_id:)
20
+ definition = SolidObjects.registrations.fetch(registration)
21
+ parent = parent_for(definition, parent_id)
22
+ existing = definition.history(parent).first
23
+ return existing.sort_key if existing
24
+
25
+ stage_transition(definition, parent, definition.machine_class.initial_state, {}, from: "", operation: "initialize_state")
26
+ end
27
+
28
+ private
29
+
30
+ # @rbs (Registration, String) -> untyped
31
+ def parent_for(definition, parent_id)
32
+ parent = definition.parent_class.find(parent_id)
33
+ raise ArgumentError, "parent does not match actor identity" unless definition.actor_id(parent) == actor_id
34
+
35
+ parent
36
+ end
37
+
38
+ # @rbs (Registration, untyped, String, Hash[String, untyped], from: String, operation: String) -> Integer
39
+ def stage_transition(definition, parent, to, metadata, from:, operation:)
40
+ registration = definition.name
41
+ parent_id = parent.id.to_s
42
+
43
+ counter = definition.association.to_s
44
+ current_key = sort_keys.fetch(counter) { definition.history(parent).maximum(:sort_key) || 0 }
45
+ sort_key = current_key + 10
46
+ self.sort_keys = sort_keys.merge(counter => sort_key)
47
+ commit_action(:statesman_transition, registration:, parent_id:, to:, metadata:, sort_key:, operation:)
48
+ if definition.after_commit == :effect
49
+ emit(:statesman_after_commit, registration:, parent_id:, sort_key:, from:)
50
+ end
51
+ sort_key
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,95 @@
1
+ # rbs_inline: enabled
2
+
3
+ module Statesman
4
+ module SolidObjects
5
+ class Adapter
6
+ # @rbs @transition_class: untyped
7
+ # @rbs @parent_model: untyped
8
+ # @rbs @observer: untyped
9
+ # @rbs @definition: Registration
10
+ # @rbs @authorization_context: untyped
11
+
12
+ attr_reader :transition_class, :parent_model
13
+
14
+ # @rbs (untyped, untyped, untyped, ?Hash[Symbol, untyped]) -> void
15
+ def initialize(transition_class, parent_model, observer, options = {})
16
+ @transition_class = transition_class
17
+ @parent_model = parent_model
18
+ @observer = observer
19
+ @definition = SolidObjects.registrations.fetch(options.fetch(:solid_objects_registration).to_s)
20
+ raise ArgumentError, "transition class does not match registration" unless transition_class == @definition.transition_class
21
+
22
+ @definition.actor_id(parent_model)
23
+ @authorization_context = options[:authorization_context]
24
+ end
25
+
26
+ # @rbs (String | Symbol | nil, String | Symbol, ?Hash[untyped, untyped]) -> untyped
27
+ def create(from, to, metadata = {})
28
+ return execution.persist(@observer, from.to_s, to.to_s, metadata) if execution
29
+
30
+ reference = Actor.ref(definition.actor_id(parent_model)).sync(authorization_context: @authorization_context)
31
+ identity = { registration: definition.name, parent_id: parent_model.id.to_s }
32
+ sort_key = if from.nil?
33
+ reference.initialize_state(**identity)
34
+ else
35
+ reference.transition(**identity, to: to.to_s, metadata:)
36
+ end
37
+ definition.history(parent_model).find_by!(sort_key:)
38
+ rescue ::SolidObjects::Rejected => rejection
39
+ raise unless [ "transition_failed", "guard_failed" ].include?(rejection.code)
40
+
41
+ from = rejection.details.fetch("from")
42
+ to = rejection.details.fetch("to")
43
+ raise Statesman::GuardFailedError.new(from, to, nil, parent_model) if rejection.code == "guard_failed"
44
+
45
+ raise Statesman::TransitionFailedError.new(from, to)
46
+ end
47
+
48
+ # @rbs (?force_reload: bool) -> untyped
49
+ def last(force_reload: false)
50
+ authorize_read("last")
51
+ transition_class.uncached { definition.history(parent_model).last }
52
+ end
53
+
54
+ # @rbs (?force_reload: bool) -> Array[untyped]
55
+ def history(force_reload: false)
56
+ authorize_read("history")
57
+ transition_class.uncached { definition.history(parent_model).to_a }
58
+ end
59
+
60
+ # @rbs () -> nil
61
+ def reset
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :definition
68
+
69
+ # @rbs () -> Integer
70
+ def next_sort_key
71
+ (last&.sort_key || 0) + 10
72
+ end
73
+
74
+ # @rbs () -> Execution?
75
+ def execution
76
+ current = Execution.current
77
+ current if current&.matches?(definition, parent_model)
78
+ end
79
+
80
+ # @rbs (String) -> void
81
+ def authorize_read(operation)
82
+ return if execution
83
+
84
+ allowed = ::SolidObjects.configuration.authorize_query.call(
85
+ actor_type: Actor.actor_type,
86
+ actor_id: definition.actor_id(parent_model),
87
+ operation:,
88
+ arguments: {},
89
+ authorization_context: @authorization_context
90
+ )
91
+ raise ::SolidObjects::Unauthorized, "transition read is not authorized" unless allowed
92
+ end
93
+ end
94
+ end
95
+ end