stroma 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73bb516679b54a0f43b33efffc0719d76211dda900024976ef6398e0b0911564
4
- data.tar.gz: 34301bc64ab57e7f5f3b0fbf9bd9d71d3a23887d83dd652706b5184d4d6237a4
3
+ metadata.gz: 3651f535308d76dc210bfb0d49b87179e20ea806e4bb8032eee5165c7f23d635
4
+ data.tar.gz: 5083f7bc1e8541832be170ee35eb9ddf4852b5fd90e76af8c6d7303066e2a927
5
5
  SHA512:
6
- metadata.gz: 2ee5cfa5a9f5f54aa8cb2b713a29cf1802abb6b98cd70f2e89eb3697652ce631e4f77017c0c8207309877e7e2289275caeabcda123d48cebd1527a0e397185d8
7
- data.tar.gz: d8f0362ad411eea72508423e3a931e360924c996d88c011924a6ca0d483d45ddbdbd83bd590fc90cc5b466785bcac232c853a9dcc40fc8eefbfb154b90444180
6
+ metadata.gz: 2b56c004b08152484972a38b47919c5b1b299d9316bbbcebc2c533190ea766dee62a9882d81ca97281874c4d47bf8e4447fa31142bd51d9329d2c8a17fe9c0da
7
+ data.tar.gz: cee73e3a9040cf6da488c3326333618d30128dc448179e9d184b991d8602ea7da3ebfcbd50eab9e1d6559b5b14214aaed90f42ad1a6e3757d435933ba3f4f12b
data/README.md CHANGED
@@ -1,4 +1,12 @@
1
- <h1 align="center">Stroma</h1>
1
+ <p align="center">
2
+ <a href="https://servactory.com" target="_blank">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/servactory/stroma/main/.github/logo-dark.svg">
5
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/servactory/stroma/main/.github/logo-light.svg">
6
+ <img alt="Stroma" src="https://raw.githubusercontent.com/servactory/stroma/main/.github/logo-light.svg" width="350" height="70" style="max-width: 100%;">
7
+ </picture>
8
+ </a>
9
+ </p>
2
10
 
3
11
  <p align="center">
4
12
  A foundation for building modular, extensible DSLs in Ruby.
@@ -75,8 +83,24 @@ end
75
83
 
76
84
  ### Usage
77
85
 
86
+ Create an intermediate class with lifecycle hooks:
87
+
78
88
  ```ruby
79
- class UserService < MyLib::Base
89
+ class ApplicationService < MyLib::Base
90
+ # Add lifecycle hooks (optional)
91
+ extensions do
92
+ before :actions, ApplicationService::Extensions::Rollbackable::DSL
93
+ end
94
+ end
95
+ ```
96
+
97
+ Build services that inherit extension functionality:
98
+
99
+ ```ruby
100
+ class UserService < ApplicationService
101
+ # DSL method from Rollbackable extension
102
+ on_rollback(...)
103
+
80
104
  input :email, type: String
81
105
 
82
106
  make :create_user
@@ -89,6 +113,12 @@ class UserService < MyLib::Base
89
113
  end
90
114
  ```
91
115
 
116
+ Extensions allow you to add cross-cutting concerns like transactions, authorization, and rollback support. See [extension examples](https://github.com/servactory/servactory/tree/main/examples/application_service/extensions) for implementation details.
117
+
118
+ ## 💎 Projects Using Stroma
119
+
120
+ - [Servactory](https://github.com/servactory/servactory) — Service objects framework for Ruby applications
121
+
92
122
  ## 🤝 Contributing
93
123
 
94
124
  We welcome contributions! Check out our [Contributing Guide](https://github.com/servactory/stroma/blob/main/CONTRIBUTING.md) to get started.
@@ -16,6 +16,19 @@ module Stroma
16
16
  # - ServiceClass gets @stroma_matrix (same reference)
17
17
  # - ServiceClass gets @stroma (unique State per class)
18
18
  #
19
+ # ## Deferred Entry Inclusion
20
+ #
21
+ # Entry extensions are NOT included via `Module#include` at the base class level.
22
+ # Instead, only the `self.included` callback is fired (via `send(:included, base)`)
23
+ # to set up ClassMethods, constants, etc. The actual module insertion into the
24
+ # ancestor chain (`append_features`) is deferred until {Hooks::Applier} interleaves
25
+ # entries with hooks in child classes.
26
+ #
27
+ # **Contract:** Entry extensions MUST implement `self.included` as idempotent.
28
+ # The callback fires twice per entry per class hierarchy:
29
+ # 1. At base class creation (deferred, via `send(:included, base)`)
30
+ # 2. At child class creation (real, via `include` in {Hooks::Applier})
31
+ #
19
32
  # ## Usage
20
33
  #
21
34
  # ```ruby
@@ -59,11 +72,11 @@ module Stroma
59
72
  # - extensions DSL for registering hooks
60
73
  #
61
74
  # @return [Module] The generated DSL module
62
- def generate # rubocop:disable Metrics/MethodLength
75
+ def generate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
63
76
  matrix = @matrix
64
77
  class_methods = build_class_methods
65
78
 
66
- Module.new do
79
+ mod = Module.new do
67
80
  @stroma_matrix = matrix
68
81
 
69
82
  class << self
@@ -75,12 +88,23 @@ module Stroma
75
88
  base.instance_variable_set(:@stroma_matrix, mtx)
76
89
  base.instance_variable_set(:@stroma, State.new)
77
90
 
78
- mtx.entries.each { |entry| base.include(entry.extension) }
91
+ # Deferred inclusion: triggers `included` callback without `append_features`.
92
+ # The callback runs ClassMethods/Workspace setup on base.
93
+ # `append_features` (actual module insertion into ancestors) is deferred
94
+ # until Applier interleaves entries with hooks in child classes.
95
+ # NOTE: `included` will fire again when Applier calls `include` on child,
96
+ # so entry extensions must design `self.included` as idempotent.
97
+ mtx.entries.each { |entry| entry.extension.send(:included, base) }
79
98
  end
80
99
  end
81
100
 
82
101
  const_set(:ClassMethods, class_methods)
83
102
  end
103
+
104
+ Utils.name_module(mod, "Stroma::DSL(#{matrix.name})")
105
+ Utils.name_module(class_methods, "Stroma::DSL(#{matrix.name})::ClassMethods")
106
+
107
+ mod
84
108
  end
85
109
 
86
110
  private
@@ -2,13 +2,15 @@
2
2
 
3
3
  module Stroma
4
4
  module Hooks
5
- # Applies registered hooks to a target class.
5
+ # Applies registered hooks to a target class with deferred entry inclusion.
6
6
  #
7
7
  # ## Purpose
8
8
  #
9
- # Includes hook extension modules into target class.
10
- # Maintains order based on matrix registry entries.
11
- # For each entry: before hooks first, then after hooks.
9
+ # Manages hook and entry module inclusion into the target class.
10
+ # Operates in three modes depending on current state:
11
+ # - No hooks: returns immediately (entries stay deferred)
12
+ # - Entries already in ancestors: includes only new hooks
13
+ # - Entries not in ancestors: interleaves entries with hooks for correct MRO
12
14
  #
13
15
  # ## Usage
14
16
  #
@@ -50,16 +52,59 @@ module Stroma
50
52
 
51
53
  # Applies all registered hooks to the target class.
52
54
  #
53
- # For each registry entry, includes before hooks first,
54
- # then after hooks. Does nothing if hooks collection is empty.
55
+ # Three modes based on current state:
56
+ # - No hooks: return immediately (defer entry inclusion)
57
+ # - Entries already in ancestors: include only hooks
58
+ # - Entries not in ancestors: interleave entries with hooks
55
59
  #
56
60
  # @return [void]
57
61
  def apply!
58
62
  return if @hooks.empty?
59
63
 
64
+ if entries_in_ancestors?
65
+ include_hooks_only
66
+ else
67
+ include_entries_with_hooks
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Checks whether all entry extensions are already in the target class ancestors.
74
+ #
75
+ # Uses all? so that partial inclusion (some entries present, some not)
76
+ # falls through to include_entries_with_hooks where Ruby skips
77
+ # already-included modules (idempotent) and interleaves the rest.
78
+ #
79
+ # @return [Boolean]
80
+ def entries_in_ancestors?
81
+ ancestors = @target_class.ancestors
82
+ @matrix.entries.all? { |e| ancestors.include?(e.extension) }
83
+ end
84
+
85
+ # Includes entries interleaved with their hooks.
86
+ #
87
+ # For each entry: after hooks first (reversed), then entry, then before hooks (reversed).
88
+ # reverse_each ensures first registered = outermost in MRO.
89
+ #
90
+ # @return [void]
91
+ def include_entries_with_hooks
92
+ @matrix.entries.each do |entry|
93
+ @hooks.after(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
94
+ @target_class.include(entry.extension)
95
+ @hooks.before(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
96
+ end
97
+ end
98
+
99
+ # Includes only hook extensions without entries.
100
+ #
101
+ # Used when entries are already in ancestors (multi-level inheritance).
102
+ #
103
+ # @return [void]
104
+ def include_hooks_only
60
105
  @matrix.entries.each do |entry|
61
- @hooks.before(entry.key).each { |hook| @target_class.include(hook.extension) }
62
- @hooks.after(entry.key).each { |hook| @target_class.include(hook.extension) }
106
+ @hooks.before(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
107
+ @hooks.after(entry.key).reverse_each { |hook| @target_class.include(hook.extension) }
63
108
  end
64
109
  end
65
110
  end
@@ -59,7 +59,7 @@ module Stroma
59
59
  # @return [void]
60
60
  def initialize_dup(original)
61
61
  super
62
- @collection = original.instance_variable_get(:@collection).dup
62
+ @collection = original.collection.dup
63
63
  end
64
64
 
65
65
  # Adds a new hook to the collection.
@@ -96,6 +96,10 @@ module Stroma
96
96
  def after(key)
97
97
  @collection.select { |hook| hook.after? && hook.target_key == key }
98
98
  end
99
+
100
+ protected
101
+
102
+ attr_reader :collection
99
103
  end
100
104
  end
101
105
  end
@@ -46,7 +46,7 @@ module Stroma
46
46
  # @param extension [Module] Extension module to include
47
47
  # @raise [Exceptions::InvalidHookType] If type is invalid
48
48
  def initialize(type:, target_key:, extension:)
49
- if VALID_HOOK_TYPES.exclude?(type)
49
+ unless VALID_HOOK_TYPES.include?(type)
50
50
  raise Exceptions::InvalidHookType,
51
51
  "Invalid hook type: #{type.inspect}. Valid types: #{VALID_HOOK_TYPES.map(&:inspect).join(', ')}"
52
52
  end
@@ -70,6 +70,7 @@ module Stroma
70
70
  return if @finalized
71
71
 
72
72
  @entries.freeze
73
+ @keys = @entries.map(&:key).freeze
73
74
  @finalized = true
74
75
  end
75
76
 
@@ -88,7 +89,7 @@ module Stroma
88
89
  # @return [Array<Symbol>] The registry keys
89
90
  def keys
90
91
  ensure_finalized!
91
- @entries.map(&:key)
92
+ @keys
92
93
  end
93
94
 
94
95
  # Checks if a key is registered.
@@ -98,7 +99,7 @@ module Stroma
98
99
  # @return [Boolean] true if the key is registered
99
100
  def key?(key)
100
101
  ensure_finalized!
101
- @entries.any? { |e| e.key == key.to_sym }
102
+ @keys.include?(key.to_sym)
102
103
  end
103
104
 
104
105
  private
@@ -61,7 +61,7 @@ module Stroma
61
61
  # @return [void]
62
62
  def initialize_dup(original)
63
63
  super
64
- @storage = original.instance_variable_get(:@storage).transform_values(&:dup)
64
+ @storage = original.storage.transform_values(&:dup)
65
65
  end
66
66
 
67
67
  # Accesses or creates RegistrySettings for a registry key.
@@ -83,6 +83,10 @@ module Stroma
83
83
  def to_h
84
84
  @storage.transform_values(&:to_h)
85
85
  end
86
+
87
+ protected
88
+
89
+ attr_reader :storage
86
90
  end
87
91
  end
88
92
  end
@@ -59,7 +59,7 @@ module Stroma
59
59
  # @return [void]
60
60
  def initialize_dup(original)
61
61
  super
62
- @storage = original.instance_variable_get(:@storage).transform_values(&:dup)
62
+ @storage = original.storage.transform_values(&:dup)
63
63
  end
64
64
 
65
65
  # Accesses or creates a Setting for an extension.
@@ -81,6 +81,10 @@ module Stroma
81
81
  def to_h
82
82
  @storage.transform_values(&:to_h)
83
83
  end
84
+
85
+ protected
86
+
87
+ attr_reader :storage
84
88
  end
85
89
  end
86
90
  end
@@ -69,7 +69,7 @@ module Stroma
69
69
  # @return [void]
70
70
  def initialize_dup(original)
71
71
  super
72
- @data = deep_dup(original.instance_variable_get(:@data))
72
+ @data = deep_dup(original.data)
73
73
  end
74
74
 
75
75
  # Converts to a plain Hash.
@@ -93,6 +93,10 @@ module Stroma
93
93
  @data.fetch(key.to_sym, *args, &block)
94
94
  end
95
95
 
96
+ protected
97
+
98
+ attr_reader :data
99
+
96
100
  private
97
101
 
98
102
  # Recursively duplicates nested Hash and Array structures.
data/lib/stroma/state.rb CHANGED
@@ -60,8 +60,8 @@ module Stroma
60
60
  # @return [void]
61
61
  def initialize_dup(original)
62
62
  super
63
- @hooks = original.instance_variable_get(:@hooks).dup
64
- @settings = original.instance_variable_get(:@settings).dup
63
+ @hooks = original.hooks.dup
64
+ @settings = original.settings.dup
65
65
  end
66
66
  end
67
67
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stroma
4
+ # Shared utility methods for the Stroma framework.
5
+ #
6
+ # ## Purpose
7
+ #
8
+ # Provides common helper methods used across multiple Stroma components.
9
+ # All methods are module functions - callable as both module methods
10
+ # and instance methods when included.
11
+ module Utils
12
+ module_function
13
+
14
+ # Assigns a temporary name to an anonymous module for debugging clarity.
15
+ # Uses set_temporary_name (Ruby 3.3+) when available.
16
+ #
17
+ # TODO: Remove the else branch when Ruby 3.2 support is dropped.
18
+ # The define_singleton_method fallback is a temporary workaround
19
+ # that only affects #inspect and #to_s. Unlike set_temporary_name,
20
+ # it does not set #name, so the module remains technically anonymous.
21
+ #
22
+ # @param mod [Module] The module to name
23
+ # @param name [String] The temporary name
24
+ # @return [void]
25
+ def name_module(mod, name)
26
+ if mod.respond_to?(:set_temporary_name)
27
+ mod.set_temporary_name(name)
28
+ else
29
+ mod.define_singleton_method(:inspect) { name }
30
+ mod.define_singleton_method(:to_s) { name }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -3,7 +3,7 @@
3
3
  module Stroma
4
4
  module VERSION
5
5
  MAJOR = 0
6
- MINOR = 4
6
+ MINOR = 5
7
7
  PATCH = 0
8
8
  PRE = nil
9
9
 
data/lib/stroma.rb CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  require "zeitwerk"
4
4
 
5
- require "active_support/all"
6
-
7
5
  loader = Zeitwerk::Loader.for_gem
8
6
  loader.ignore("#{__dir__}/stroma/test_kit/rspec")
9
7
  loader.inflector.inflect(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stroma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anton Sokolov
@@ -9,20 +9,6 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: activesupport
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '5.1'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '5.1'
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: zeitwerk
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -151,6 +137,7 @@ files:
151
137
  - lib/stroma/settings/registry_settings.rb
152
138
  - lib/stroma/settings/setting.rb
153
139
  - lib/stroma/state.rb
140
+ - lib/stroma/utils.rb
154
141
  - lib/stroma/version.rb
155
142
  homepage: https://github.com/servactory/stroma
156
143
  licenses:
@@ -174,7 +161,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
174
161
  - !ruby/object:Gem::Version
175
162
  version: '0'
176
163
  requirements: []
177
- rubygems_version: 3.6.9
164
+ rubygems_version: 4.0.6
178
165
  specification_version: 4
179
166
  summary: Foundation for building modular, extensible DSLs
180
167
  test_files: []