ruby-object-framework 1.0.0 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a1e2d4ec32fc727f2bf3ace15ac7d7b258b4ebcbe8ec3b3e23402138c45225e6
4
- data.tar.gz: e86edf614e5007fb417230d2fc590f5f1a3b356bea297301cba9154e7e31d9b5
3
+ metadata.gz: ce4bd20189a52219bc14f74365a6266ea8a86c6a4ff75a8893357c5405ec71ab
4
+ data.tar.gz: 5f3b4b8cc2d8acc3396a5bb3d82e859439ce6fe29f5fdb5f50499ca7d00ccfcf
5
5
  SHA512:
6
- metadata.gz: b3afe27f57481683738558c93ad8c32b48b002eb26b271bec74efff482c4523a374a6aeeff0e0c12725ef569f408c9239629aad069a21efa10971269b2dd0262
7
- data.tar.gz: a67817b8b87c0b5b0486deb700bce6670f8a2c43a70bb26a2e8122446c85d51884073827e3a4679fd45d54beb74ee33b223324a4d5168809bae425758fad5e25
6
+ metadata.gz: cfb86252c9efb72eaa78f6eb3718d9d63b4d5474857a4632236a59a77b6fcac069f7ce724c056a8f9d6b1ae594edf5fc06d0cdb85c4ea0d12c66e611d2eb7f43
7
+ data.tar.gz: 36649ffd4678ac362bc7b46b70285313909523ad9a670310d3fd332b4cba01b577c6b7e263746c8c8ce3ae5bd5253956b95276639c6b6250599c70687baaf992
data/API.md CHANGED
@@ -8,9 +8,18 @@ the [ObjectFramework Guides](https://gstn-caruso.github.io/ruby-object-framework
8
8
  ## Application composition and execution
9
9
 
10
10
  Start with [Application](rdoc-ref:ObjectFramework::Application). Register
11
- invocation-scoped collaborators with `factory`, declare use cases with
12
- `operation`, and finalize the configuration with `start`. Always call `stop`
13
- in an `ensure` block.
11
+ invocation-scoped collaborators with `bean` or `factory`, declare use cases
12
+ with `operation`, and finalize the configuration with `start`. Always call
13
+ `stop` in an `ensure` block.
14
+
15
+ `bean(:name, SomeClass)` registers an ordinary Ruby class;
16
+ `bean(:name) { |dependency:| ... }` registers a builder. Beans and operations
17
+ infer dependencies from required constructor or block keywords when
18
+ `dependencies:` is omitted. Optional keywords retain their defaults unless
19
+ explicitly listed. Beans share the factory namespace and are constructed
20
+ lazily once per invocation. Every subsequent invocation gets fresh instances.
21
+ `close: true` closes a bean in reverse construction order on success or failure.
22
+ `factory` keeps its explicit dependency list.
14
23
 
15
24
  Each operation declares `policy: :none`, `:read`, or `:write`. Transactional
16
25
  operations require storage. Dependencies are explicit constructor keywords;
data/README.md CHANGED
@@ -7,6 +7,9 @@ invocation owns its resources and transaction.
7
7
  Read the [ObjectFramework Guides](https://gstn-caruso.github.io/ruby-object-framework/)
8
8
  for progressive tutorials, executable examples, and the reasoning behind the
9
9
  framework's design. Start with [Your first application](https://gstn-caruso.github.io/ruby-object-framework/getting_started.html).
10
+ The [RDoc API reference](https://gstn-caruso.github.io/ruby-object-framework/api/index.html)
11
+ documents classes, signatures, and execution contracts. You can also browse it
12
+ inside the guides' [API reference section](https://gstn-caruso.github.io/ruby-object-framework/#api-reference).
10
13
 
11
14
  Development follows the six deliveries in [PLAN.md](PLAN.md). See the
12
15
  [implementation record](IMPLEMENTATION.md) for the current delivery status.
@@ -46,6 +49,8 @@ trusted-publisher registration and local packaging commands.
46
49
 
47
50
  Ruby **4.0.8** is required. Version files support asdf and other Ruby managers.
48
51
  The core and test runner use Ruby core and the standard library.
52
+ The guide suite and documentation build additionally use the development-only
53
+ RDoc dependency; run `bundle install` before running the full suite.
49
54
 
50
55
  ```sh
51
56
  ruby -Ilib:test test/run.rb
@@ -93,8 +98,8 @@ class Greet
93
98
  end
94
99
 
95
100
  app = ObjectFramework::Application.new
96
- app.factory(:greeting) { "Hello" }
97
- app.operation(:greet, use_case: Greet, policy: :none, dependencies: [:greeting])
101
+ app.bean(:greeting) { "Hello" }
102
+ app.operation(:greet, use_case: Greet, policy: :none)
98
103
  app.start
99
104
  begin
100
105
  app.invoke(:greet, name: "Ada") # => { message: "Hello, Ada" }
@@ -110,6 +115,19 @@ invocation, may declare their own dependencies, and may opt into cleanup with
110
115
  operation identifier and cooperative cancellation. Application registrations
111
116
  are finalized at startup. Separate application instances have separate registries.
112
117
 
118
+ Register request-scoped beans with `app.bean(:name, SomeClass)` or
119
+ `app.bean(:name) { |dependency:| ... }`. Beans and operations infer dependencies
120
+ from required constructor or block keywords. Optional keywords keep their
121
+ defaults; pass `dependencies: [...]` to inject them explicitly. Classes remain
122
+ ordinary Ruby objects, and `factory` remains available for explicit wiring.
123
+
124
+ Every invocation constructs only the beans it needs, reuses each named bean
125
+ within that invocation, and discards them afterward. The next request gets fresh
126
+ instances, including on the same keep-alive connection. Resource beans can use
127
+ `close: true` for cleanup after success or failure. See the
128
+ [composition guide](https://gstn-caruso.github.io/ruby-object-framework/composition_and_execution.html#register-request-scoped-beans)
129
+ and run `ruby -Ilib examples/guides/14_request_beans.rb` for a complete example.
130
+
113
131
  Public results contain independent, immutable values: strings, numbers,
114
132
  booleans, symbols, nil, arrays, hashes, and `Data` objects composed of these.
115
133
  Returning an unsupported object or a cycle fails before commit. Managed domain
@@ -140,6 +158,10 @@ Keep public APIs, tests, documentation, and explanatory comments in English.
140
158
  Start a behavior change with a failing scenario in the dependency-free runner,
141
159
  then implement it and run the current delivery gate. Comments explaining
142
160
  protocol constraints and non-obvious decisions are welcome.
161
+ Document public classes and methods with RDoc comments in `lib/`, and update
162
+ the [API overview](API.md) when their contracts change. Build both the guides
163
+ and API reference with `bundle exec ruby tools/build_guides.rb`; see the
164
+ [documentation authoring instructions](docs/README.md) for preview and publishing.
143
165
 
144
166
  ## HTTP order example
145
167
 
@@ -12,6 +12,15 @@ the font files in `docs/assets/fonts/Inter-LICENSE.txt` and
12
12
  - IBM Plex: https://github.com/IBM/plex, revision
13
13
  `71f558992315a12ca1f06a2215d57962a1992162`.
14
14
 
15
+ The static Inter regular, semibold, bold, and italic files were obtained from
16
+ `https://rubyonrails.org/assets/fonts/Inter-*.woff2?v=4.0`, the same URLs used
17
+ by Rails Guides, on October 23, 2026. They retain the Inter license above.
18
+ IBM Plex Mono Text uses the exact file served at
19
+ `https://rubyonrails.org/assets/fonts/IBMPlexMono-Text.woff2`, obtained on
20
+ October 23, 2026, under the IBM Plex license above.
21
+ Calibre is loaded from the Rails-hosted font URLs declared in
22
+ `docs/assets/fonts.css`; Calibre font files are not distributed in this repository.
23
+
15
24
  The documentation stylesheet is an original implementation of the visual
16
25
  reference requested by the project: https://guides.rubyonrails.org/index.html.
17
26
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "object_framework"
4
+
5
+ class RequestCounter
6
+ attr_reader :operation_id
7
+
8
+ def initialize(context:)
9
+ @operation_id, @count = context.operation_id, 0
10
+ end
11
+
12
+ def next_value
13
+ @count += 1
14
+ end
15
+
16
+ def close
17
+ puts "Counter closed"
18
+ end
19
+ end
20
+
21
+ class CountTwice
22
+ def initialize(first:, second:)
23
+ @first, @second = first, second
24
+ end
25
+
26
+ def call
27
+ { counts: [@first.next_value, @second.next_value], operation_id: @first.operation_id }
28
+ end
29
+ end
30
+
31
+ app = ObjectFramework::Application.new
32
+ app.bean(:counter, RequestCounter, close: true)
33
+ app.bean(:first) { |counter:| counter }
34
+ app.bean(:second) { |counter:| counter }
35
+ app.operation(:count, use_case: CountTwice, policy: :none)
36
+ app.start
37
+ begin
38
+ results = 2.times.map do |index|
39
+ result = app.invoke(:count)
40
+ puts "Request #{index + 1}: #{result[:counts].join(', ')}"
41
+ result
42
+ end
43
+ puts "Independent contexts: #{results[0][:operation_id] != results[1][:operation_id]}"
44
+ ensure
45
+ app.stop
46
+ end
@@ -60,3 +60,10 @@
60
60
  PASS a price includes twenty percent tax
61
61
  PASS negative prices are rejected
62
62
  3 tests, 0 failures
63
+ - file: 14_request_beans.rb
64
+ output: |
65
+ Counter closed
66
+ Request 1: 1, 2
67
+ Counter closed
68
+ Request 2: 1, 2
69
+ Independent contexts: true
@@ -4,9 +4,23 @@ module ObjectFramework
4
4
  Factory = Data.define(:dependencies, :build, :close)
5
5
  Operation = Data.define(:use_case, :policy, :dependencies)
6
6
 
7
+ # Composes use cases, scoped collaborators, and lifecycle modules.
8
+ # Registrations belong to this instance and become immutable at #start.
9
+ #
10
+ # app = ObjectFramework::Application.new
11
+ # app.factory(:greeting) { "Hello" }
12
+ # app.operation(:greet, use_case: Greet, policy: :none, dependencies: [:greeting])
13
+ # app.start
14
+ # begin
15
+ # app.invoke(:greet, name: "Ada")
16
+ # ensure
17
+ # app.stop
18
+ # end
7
19
  class Application
8
20
  attr_reader :errors, :reporter
9
21
 
22
+ # Creates an independent composition root. Transactional operations need
23
+ # +storage+; the reserved +session+ dependency also needs +mappings+.
10
24
  def initialize(storage: nil, mappings: nil, errors: ErrorRegistry.new, reporter: nil)
11
25
  @storage, @mappings, @errors = storage, mappings, errors
12
26
  @reporter = reporter || FailureReporter.new(registry: errors)
@@ -14,6 +28,11 @@ module ObjectFramework
14
28
  @started, @finalized = false, false
15
29
  end
16
30
 
31
+ # Registers an invocation-scoped collaborator, constructed lazily once per
32
+ # invocation. The block receives its declared dependencies as keywords.
33
+ # With <tt>close: true</tt>, cleanup calls the collaborator's +close+ in
34
+ # reverse construction order, including when the operation fails.
35
+ # +context+, +session+, and +transaction+ are reserved names. Returns +self+.
17
36
  def factory(name, dependencies: [], close: false, &build)
18
37
  mutable!
19
38
  raise ConfigurationError, "Reserved execution dependency: #{name}" if %i[context session transaction].include?(name)
@@ -23,14 +42,40 @@ module ObjectFramework
23
42
  self
24
43
  end
25
44
 
26
- def operation(name, use_case:, policy:, dependencies: [])
45
+ # Registers an invocation-scoped bean from a Ruby class or a block.
46
+ # Required constructor/block keywords name its dependencies unless an
47
+ # explicit +dependencies+ list is supplied. Optional keywords keep their
48
+ # defaults. Beans are lazy, shared only within one invocation, and released
49
+ # with that invocation when <tt>close: true</tt>. Returns +self+.
50
+ #
51
+ # app.bean(:orders, Orders)
52
+ # app.bean(:client, close: true) { |context:| Client.new(context:) }
53
+ def bean(name, type = nil, dependencies: nil, close: false, &build)
54
+ raise ConfigurationError, "A bean accepts either a class or a block" if type && build
55
+ unless type.is_a?(Class) || (type.nil? && build)
56
+ raise ConfigurationError, "A bean requires a class or a block"
57
+ end
58
+ dependencies ||= keyword_dependencies(type ? type.instance_method(:initialize) : build)
59
+ build ||= ->(**arguments) { type.new(**arguments) }
60
+ factory(name, dependencies:, close:, &build)
61
+ end
62
+
63
+ # Registers a class implementing +call+. Dependencies become constructor
64
+ # keywords; omitting +dependencies+ infers required constructor keywords.
65
+ # #invoke arguments become +call+ keywords. +policy+ must be +:none+,
66
+ # +:read+, or +:write+. Returns +self+.
67
+ def operation(name, use_case:, policy:, dependencies: nil)
27
68
  mutable!
28
69
  raise ConfigurationError, "Operation #{name} already registered" if @operations.key?(name)
29
70
  raise ConfigurationError, "Unknown transaction policy" unless %i[none read write].include?(policy)
71
+ dependencies ||= use_case.is_a?(Class) ? keyword_dependencies(use_case.instance_method(:initialize)) : []
30
72
  @operations[name] = Operation.new(use_case:, policy:, dependencies: dependencies.dup.freeze)
31
73
  self
32
74
  end
33
75
 
76
+ # Registers a lifecycle module implementing +name+, +requires+, +configure+,
77
+ # +start+, and +stop+, and optionally +validate!+. Modules start in dependency
78
+ # order and stop in reverse order. Returns +self+.
34
79
  def install(mod)
35
80
  mutable!
36
81
  raise ConfigurationError, "Module #{mod.name} already registered" if @modules.key?(mod.name)
@@ -38,6 +83,9 @@ module ObjectFramework
38
83
  self
39
84
  end
40
85
 
86
+ # Configures modules, validates dependencies and policies, freezes
87
+ # registrations, and starts modules. Raises ConfigurationError for invalid
88
+ # composition. If startup fails, already-started modules are stopped.
41
89
  def start
42
90
  raise ConfigurationError, "Application is already started" if @started
43
91
  unless @finalized
@@ -62,6 +110,8 @@ module ObjectFramework
62
110
  raise
63
111
  end
64
112
 
113
+ # Stops started modules in reverse order, attempting every stop even if one
114
+ # fails. Preserves an exception already unwinding the caller. Returns +self+.
65
115
  def stop
66
116
  error = nil
67
117
  @started_modules.reverse_each do |mod|
@@ -75,6 +125,11 @@ module ObjectFramework
75
125
  self
76
126
  end
77
127
 
128
+ # Executes a registered use case in a fresh resource and transaction scope.
129
+ # Returns an independent, frozen value graph before resources are closed.
130
+ # Unsupported results raise UnsafeResult before flush or commit. Failures
131
+ # are reported and re-raised; failed commits are never automatically retried.
132
+ # The application must be started. +context+ defaults to a fresh context.
78
133
  def invoke(name, context: ExecutionContext.new, **arguments)
79
134
  raise ConfigurationError, "Application is not started" unless @started
80
135
  operation = @operations.fetch(name) { raise ConfigurationError, "Unknown operation: #{name}" }
@@ -92,6 +147,14 @@ module ObjectFramework
92
147
 
93
148
  private
94
149
 
150
+ def keyword_dependencies(callable)
151
+ parameters = callable.parameters
152
+ if parameters.any? { |kind, _| kind == :req }
153
+ raise ConfigurationError, "Automatic injection requires keyword arguments; wrap positional constructors in a bean block"
154
+ end
155
+ parameters.filter_map { |kind, name| name if kind == :keyreq }
156
+ end
157
+
95
158
  def mutable!
96
159
  raise ConfigurationError, "Application registrations are finalized" if @finalized
97
160
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
+ # Optional Ruby Box, Ractor, and JIT capability reporting. The core application
5
+ # lifecycle does not require these experimental or VM-specific features.
4
6
  module Capabilities
5
7
  class Unsupported < ConfigurationError; end
6
8
 
@@ -13,11 +15,16 @@ module ObjectFramework
13
15
  end
14
16
  end
15
17
 
18
+ # Returns the current availability/enabled state of Ruby Box, Ractor,
19
+ # YJIT, and ZJIT. JIT comparisons run in separate benchmark processes.
16
20
  def self.report
17
21
  { ruby_box: Ruby::Box.enabled?, ractor: defined?(::Ractor) != nil,
18
22
  yjit: RubyVM::YJIT.enabled?, zjit: RubyVM::ZJIT.enabled? }.freeze
19
23
  end
20
24
 
25
+ # Owns application definitions in a separate Ruby Box.
26
+ # Requires Ruby to start with RUBY_BOX=1; enabling it later has no effect.
27
+ # Raises Unsupported when Ruby Box is disabled.
21
28
  class Definitions
22
29
  def initialize
23
30
  raise Unsupported, "Enable Ruby Box at process boot with RUBY_BOX=1" unless Ruby::Box.enabled?
@@ -30,12 +37,18 @@ module ObjectFramework
30
37
  def constant(name); @box.const_get(name, false); end
31
38
  end
32
39
 
40
+ # Runs a class/module's +self.call(input)+ in a new Ractor over independent
41
+ # values. Sessions and execution contexts stay on the owning request thread.
33
42
  class IsolatedTask
34
43
  def initialize(task:)
35
44
  raise ConfigurationError, "An isolated task must be a class or module implementing call" unless task.is_a?(Module) && task.respond_to?(:call)
36
45
  @task = task
37
46
  end
38
47
 
48
+ # Returns a detached task result. Checks optional context cancellation
49
+ # before and after the computation; cannot interrupt a running Ractor.
50
+ # Remote StandardError failures become TaskFailure with the remote class
51
+ # name and backtrace. Use bounded computations.
39
52
  def call(input, context: nil)
40
53
  context&.cancellation&.check!
41
54
  payload = Values.detach(input)
@@ -15,6 +15,8 @@ module ObjectFramework
15
15
 
16
16
  ErrorDescription = Data.define(:category, :code, :title, :details)
17
17
 
18
+ # Maps exception classes to safe, transport-independent public descriptions.
19
+ # Configure it before Application#start finalizes registrations.
18
20
  class ErrorRegistry
19
21
  CATEGORIES = %i[bad_request invalid_input unauthenticated forbidden not_found conflict
20
22
  duplicate transaction_conflict rate_limited capacity unavailable
@@ -35,6 +37,10 @@ module ObjectFramework
35
37
  DEFAULTS.each { |type, (category, code, title)| register(type, category:, code:, title:) }
36
38
  end
37
39
 
40
+ # Registers a StandardError subclass with a stable +code+ and +title+.
41
+ # +category+ must belong to CATEGORIES. The optional +details+ callable must
42
+ # return only explicitly safe public values. Duplicate types are rejected.
43
+ # Returns +self+.
38
44
  def register(type, category:, code:, title:, details: ->(_) { {} })
39
45
  raise ConfigurationError, "Error registry is finalized" if @registrations.frozen?
40
46
  raise ConfigurationError, "Expected an exception class" unless type.is_a?(Class) && type <= StandardError
@@ -52,6 +58,8 @@ module ObjectFramework
52
58
  self
53
59
  end
54
60
 
61
+ # Returns an ErrorDescription for the closest registered ancestor.
62
+ # Unregistered exceptions receive a generic internal-error description.
55
63
  def resolve(exception)
56
64
  type = exception.class.ancestors.find { |ancestor| @registrations.key?(ancestor) }
57
65
  return ErrorDescription.new(category: :internal, code: "internal_error", title: "Internal server error", details: {}) unless type
@@ -61,11 +69,17 @@ module ObjectFramework
61
69
  end
62
70
  end
63
71
 
72
+ # Reports one primary failure per ExecutionContext and secondary cleanup
73
+ # failures. Records exclude exception messages and input values.
64
74
  class FailureReporter
75
+ # +sink+ receives immutable diagnostic hashes. Sink failures are suppressed
76
+ # so diagnostics cannot replace the original application failure.
65
77
  def initialize(sink: ->(record) {}, registry: ErrorRegistry.new)
66
78
  @sink, @registry = sink, registry
67
79
  end
68
80
 
81
+ # Sends operation ID, public code, exception/cause class names, backtrace,
82
+ # elapsed duration, transaction outcome, and the +secondary+ flag to the sink.
69
83
  def report(exception, context:, secondary: false)
70
84
  return if !secondary && !context.claim_failure
71
85
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
+ # Copies storage state and detaches values crossing an invocation boundary.
4
5
  module Values
5
6
  # Storage values are immutable; domain state receives its own editable copy.
6
7
  def self.mutable_copy(value)
@@ -13,6 +14,8 @@ module ObjectFramework
13
14
  end
14
15
  end
15
16
 
17
+ # Returns an independent, immutable graph of supported scalar, Array, Hash,
18
+ # and Data values. Raises UnsafeResult for cycles and unsupported objects.
16
19
  def self.detach(value, visiting = {})
17
20
  composite = value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(Data)
18
21
  if composite
@@ -32,11 +35,15 @@ module ObjectFramework
32
35
  end
33
36
  end
34
37
 
38
+ # Thread-safe cooperative cancellation and a monotonic-clock deadline.
39
+ # Work must call #check! at suitable boundaries to observe either condition.
35
40
  class Cancellation
41
+ # +deadline+ is an absolute monotonic timestamp, or +nil+ for no deadline.
36
42
  def initialize(deadline: nil, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
37
43
  @deadline, @clock, @mutex, @cancelled = deadline, clock, Mutex.new, false
38
44
  end
39
45
 
46
+ # Requests cancellation without interrupting or terminating the worker.
40
47
  def cancel
41
48
  @mutex.synchronize { @cancelled = true }
42
49
  end
@@ -49,6 +56,8 @@ module ObjectFramework
49
56
  @mutex.synchronize { @deadline = deadline }
50
57
  end
51
58
 
59
+ # Raises Cancelled if cancellation was requested, or DeadlineExceeded if
60
+ # the monotonic deadline has passed.
52
61
  def check!
53
62
  raise Cancelled, "Operation cancelled" if @mutex.synchronize { @cancelled }
54
63
  limit = deadline
@@ -56,10 +65,14 @@ module ObjectFramework
56
65
  end
57
66
  end
58
67
 
68
+ # Invocation metadata shared by execution, transport, and diagnostics.
69
+ # Inject it through the reserved +context+ dependency; do not share sessions
70
+ # or this context with isolated tasks.
59
71
  class ExecutionContext
60
72
  attr_reader :operation_id, :cancellation
61
73
  attr_accessor :transaction_state
62
74
 
75
+ # Generates an operation UUID and records a monotonic start time.
63
76
  def initialize(cancellation: Cancellation.new)
64
77
  @operation_id = SecureRandom.uuid.freeze
65
78
  @cancellation, @started_at = cancellation, monotonic
@@ -67,6 +80,7 @@ module ObjectFramework
67
80
  @mutex = Mutex.new
68
81
  end
69
82
 
83
+ # Returns the elapsed time in seconds since construction.
70
84
  def elapsed
71
85
  monotonic - @started_at
72
86
  end
@@ -85,6 +99,7 @@ module ObjectFramework
85
99
  end
86
100
  end
87
101
 
102
+ # Resolves and closes the collaborators belonging to one invocation.
88
103
  class Scope
89
104
  def initialize(factories, context:)
90
105
  @factories, @context, @instances, @resources = factories, context, { context: }, []
@@ -114,6 +129,8 @@ module ObjectFramework
114
129
  end
115
130
  end
116
131
 
132
+ # Executes a use case, detaches its result, and owns transaction cleanup.
133
+ # Applications normally enter this lifecycle through Application#invoke.
117
134
  class Invocation
118
135
  def initialize(operation:, factories:, storage:, reporter:, mappings: nil)
119
136
  @operation, @factories, @storage, @reporter, @mappings = operation, factories, storage, reporter, mappings
@@ -66,9 +66,18 @@ module ObjectFramework
66
66
  end
67
67
  end
68
68
 
69
+ # Application lifecycle module serving unary and streaming gRPC calls.
70
+ # Every admitted RPC receives a new thread and an ExecutionContext.
71
+ # Controllers own the choice of operation boundaries within a stream.
72
+ # Install through Application#install before starting the application.
69
73
  class Server
70
74
  attr_reader :name, :port
71
75
 
76
+ # +services+ contains Service descriptors; +controllers+ maps their keys
77
+ # to callable controllers. Limits bound requests, connections, message
78
+ # bytes, and header bytes. Timeouts are in seconds; cancellation is
79
+ # cooperative. Optional +tls_context+ must supply a certificate and key;
80
+ # startup configures HTTP/2 ALPN. Compression supports identity only.
72
81
  def initialize(application:, services:, controllers:, host: "127.0.0.1", port: 0,
73
82
  name: :grpc, max_requests: 128, max_connections: 128,
74
83
  message_limit: 1024 * 1024, header_limit: 32 * 1024,
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
+ # Native gRPC services and streaming over the framework's HTTP/2 transport.
4
5
  module GRPC
5
6
  Method = Data.define(:name, :input, :output, :controller, :client_streaming, :server_streaming)
6
7
 
8
+ # Declares a gRPC service using Protobuf::Message contracts.
9
+ # Descriptors export proto3 source without generated Ruby message classes.
7
10
  class Service
8
11
  attr_reader :name, :methods
9
12
 
13
+ # +name+ is a qualified service name, for example +orders.Orders+.
10
14
  def initialize(name)
11
15
  parts = name.to_s.split(".", -1)
12
16
  raise ConfigurationError, "Invalid service name" if parts.empty?
@@ -14,6 +18,9 @@ module ObjectFramework
14
18
  @name, @methods = name.to_s.dup.freeze, {}
15
19
  end
16
20
 
21
+ # Adds an RPC with input/output message descriptors and a controller key.
22
+ # Both streaming flags default to false (unary); enable one for client
23
+ # or server streaming, or both for bidirectional streaming. Returns +self+.
17
24
  def rpc(name, input:, output:, controller:, client_streaming: false, server_streaming: false)
18
25
  raise ConfigurationError, "Service is finalized" if @methods.frozen?
19
26
  name = Protobuf.name(name)
@@ -29,6 +36,8 @@ module ObjectFramework
29
36
  freeze
30
37
  end
31
38
 
39
+ # Returns a complete proto3 contract with dependent messages and enums.
40
+ # Raises ConfigurationError if distinct descriptors reuse a type name.
32
41
  def to_proto
33
42
  definitions = {}
34
43
  visit = lambda do |type|
@@ -4,6 +4,8 @@ module ObjectFramework
4
4
  module HTTP
5
5
  Response = Data.define(:status, :headers, :body)
6
6
 
7
+ # Converts independent result values into a JSON Response with status 200.
8
+ # Data objects are represented as JSON objects.
7
9
  class JSONPresenter
8
10
  def call(value)
9
11
  Response.new(status: 200, headers: { "Content-Type" => "application/json" }, body: JSON.generate(json_value(value)))
@@ -21,6 +23,8 @@ module ObjectFramework
21
23
  end
22
24
  end
23
25
 
26
+ # Presents registered safe errors as RFC 9457 problem details, including
27
+ # the ExecutionContext operation ID. Exception messages are not exposed.
24
28
  class ProblemPresenter
25
29
  STATUSES = {
26
30
  bad_request: 400, invalid_input: 422, unauthenticated: 401, forbidden: 403,
@@ -1,15 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
+ # HTTP/1.1 routing, presentation, and bounded request serving.
4
5
  module HTTP
5
6
  Route = Data.define(:method, :pattern, :controller, :expression, :names)
6
7
  Match = Data.define(:controller, :params, :allow)
7
8
 
9
+ # Maps method/path patterns to explicit controller instances.
10
+ # Configuration is finalized by the server at application startup.
11
+ #
12
+ # router.controller(:confirm, controller)
13
+ # router.route(:post, "/orders/:id/confirm", controller: :confirm)
8
14
  class Router
9
15
  def initialize
10
16
  @controllers, @routes = {}, []
11
17
  end
12
18
 
19
+ # Registers a controller implementing +call(request, context:)+.
20
+ # Controllers normalize transport inputs and invoke application operations.
13
21
  def controller(name, instance)
14
22
  mutable!
15
23
  raise ConfigurationError, "Duplicate controller: #{name}" if @controllers.key?(name)
@@ -17,6 +25,8 @@ module ObjectFramework
17
25
  self
18
26
  end
19
27
 
28
+ # Adds a method and path pattern with optional +:parameter+ segments.
29
+ # Static routes take precedence over parameterized routes. Returns +self+.
20
30
  def route(method, pattern, controller:)
21
31
  mutable!
22
32
  method = method.to_s.upcase
@@ -49,6 +59,9 @@ module ObjectFramework
49
59
  self
50
60
  end
51
61
 
62
+ # Returns a Match with controller, decoded path parameters, and allowed
63
+ # methods. Supports HEAD through GET and automatic OPTIONS. Raises
64
+ # NotFound, MethodNotAllowed, or BadRequest for invalid requests.
52
65
  def resolve(method, path)
53
66
  matching = path == "*" ? @routes : @routes.select { |route| route.expression.match?(path) }
54
67
  raise NotFound, "Route not found" if matching.empty?
@@ -13,9 +13,17 @@ module ObjectFramework
13
13
  end
14
14
  end
15
15
 
16
+ # Application lifecycle module serving HTTP/1.1 on a dispatcher and one new
17
+ # thread per admitted request. Keep-alive requests execute sequentially.
18
+ # Install with Application#install before startup; the application owns
19
+ # listener startup, cooperative shutdown, and controller validation.
16
20
  class Server
17
21
  attr_reader :port, :name
18
22
 
23
+ # Configures admission limits and timeouts in seconds. +port+ zero lets
24
+ # the OS assign a port, available through #port after startup.
25
+ # +tls_context+ is an optional OpenSSL context with application-provided
26
+ # certificate and private key. Execution cancellation is cooperative.
19
27
  def initialize(application:, router:, host: "127.0.0.1", port: 0, name: :http,
20
28
  parser: Parser.new, presenter: JSONPresenter.new, problem_presenter: nil,
21
29
  max_requests: 128, max_connections: 1024, header_timeout: 10,
@@ -1,12 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
+ # Volatile, process-local persistence using revision-checked snapshots.
4
5
  module Memory
6
+ # Thread-safe storage adapter. Writes publish their complete snapshot
7
+ # atomically; a competing commit raises Conflict without automatic retry.
8
+ # Data is lost when the storage is closed or the process exits.
5
9
  class Storage
6
10
  def initialize
7
11
  @mutex, @tables, @revision, @closed = Mutex.new, {}, 0, false
8
12
  end
9
13
 
14
+ # Opens an independent snapshot in +:read+ or +:write+ mode.
15
+ # The caller must close the transaction; Application#invoke owns this
16
+ # lifecycle when the adapter is installed in an application.
10
17
  def begin_transaction(mode:)
11
18
  @mutex.synchronize do
12
19
  raise Unavailable, "Storage is closed" if @closed
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
+ # External domain mappings, immutable queries, and invocation-owned sessions.
4
5
  module Persistence
6
+ # Converts a field between domain values and independent storage values.
5
7
  class Converter
6
8
  def initialize(dump: ->(value) { value }, load: ->(value) { value })
7
9
  @dump, @load = dump, load
@@ -15,7 +17,32 @@ module ObjectFramework
15
17
  Field = Data.define(:name, :column, :type, :converter, :reader, :writer)
16
18
  Association = Data.define(:name, :kind, :target, :foreign_key, :reader, :writer)
17
19
 
20
+ # Explicit association declarations used by Mapping.
18
21
  module AssociationDSL
22
+ ##
23
+ # :method: belongs_to
24
+ # :args: name, target:, foreign_key:, read: nil, write: nil
25
+ #
26
+ # Declares a reference to +target+, storing its UUID in +foreign_key+ on
27
+ # this mapping's table. The target must have a registered Mapping.
28
+ # Optional +read+ and +write+ callables replace public attribute accessors.
29
+ # Returns +self+. Associations load only when requested through a
30
+ # Session#find or Session#query load plan.
31
+ #
32
+ # belongs_to :customer, target: Customer, foreign_key: :customer_id
33
+
34
+ ##
35
+ # :method: has_many
36
+ # :args: name, target:, foreign_key:, read: nil, write: nil
37
+ #
38
+ # Declares a collection of +target+ entities whose +foreign_key+ refers
39
+ # to this entity's UUID. The target mapping must expose that foreign key
40
+ # as a field or a belongs-to association. Optional +read+ and +write+
41
+ # callables replace public attribute accessors. Returns +self+.
42
+ # Collections load only through an explicit session load plan.
43
+ #
44
+ # has_many :purchases, target: Purchase, foreign_key: :customer_id
45
+
19
46
  %i[belongs_to has_many].each do |kind|
20
47
  define_method(kind) do |name, target:, foreign_key:, read: nil, write: nil|
21
48
  mutable!
@@ -33,6 +60,9 @@ module ObjectFramework
33
60
  end
34
61
  end
35
62
 
63
+ # Describes how an ordinary Ruby class is hydrated and stored.
64
+ # An +id+ field mapped to the +id+ column is required. Declarations become
65
+ # immutable when their Mappings collection is validated at startup.
36
66
  class Mapping
37
67
  include AssociationDSL
38
68
  TYPES = %i[string integer float boolean blob].freeze
@@ -44,6 +74,9 @@ module ObjectFramework
44
74
  @builder = ->(attributes) { entity_class.new(**attributes) }
45
75
  end
46
76
 
77
+ # Maps an attribute to a column using a type in TYPES. Optional +read+
78
+ # and +write+ callables replace public attribute accessors. +converter+
79
+ # supplies +dump+ and +load+ transformations. Returns +self+.
47
80
  def field(name, column: name, type: :string, converter: Converter.new, read: nil, write: nil)
48
81
  mutable!
49
82
  name, column = Query.identifier(name), Query.identifier(column)
@@ -55,6 +88,8 @@ module ObjectFramework
55
88
  self
56
89
  end
57
90
 
91
+ # Replaces keyword construction with a callable receiving an attributes
92
+ # hash. Use this when hydration needs a domain-specific constructor.
58
93
  def build(&builder)
59
94
  mutable!
60
95
  @builder = builder
@@ -107,6 +142,13 @@ module ObjectFramework
107
142
  end
108
143
  end
109
144
 
145
+ # Owns the application's mappings without modifying domain classes.
146
+ #
147
+ # mappings = ObjectFramework::Persistence::Mappings.new
148
+ # mappings.define(Order, table: :orders) do
149
+ # field :id
150
+ # field :status
151
+ # end
110
152
  class Mappings
111
153
  include Enumerable
112
154
 
@@ -114,6 +156,8 @@ module ObjectFramework
114
156
  @mappings = {}
115
157
  end
116
158
 
159
+ # Registers one Mapping per domain class and table. Evaluates the
160
+ # declaration block against that mapping and returns it.
117
161
  def define(entity_class, table:, &description)
118
162
  raise ConfigurationError, "Mappings are finalized" if @mappings.frozen?
119
163
  raise ConfigurationError, "Duplicate entity mapping" if @mappings.key?(entity_class)
@@ -4,6 +4,10 @@ module ObjectFramework
4
4
  module Persistence
5
5
  Criterion = Data.define(:field, :operator, :value)
6
6
 
7
+ # Immutable query shared by storage adapters. Filters and ordering produce
8
+ # new queries; rows use +id+ as a deterministic final ordering key.
9
+ #
10
+ # Query.new.where(status: "ready").order(:id).limit(20).offset(40)
7
11
  class Query
8
12
  OPERATORS = %i[eq ne gt gte lt lte in].freeze
9
13
  attr_reader :table, :criteria, :ordering, :row_limit, :row_offset
@@ -24,10 +28,13 @@ module ObjectFramework
24
28
  copy(table:)
25
29
  end
26
30
 
31
+ # Adds equality criteria for every supplied field. Returns a new query.
27
32
  def where(**attributes)
28
33
  attributes.reduce(self) { |query, (field, value)| query.compare(field, :eq, value) }
29
34
  end
30
35
 
36
+ # Adds +:eq+, +:ne+, +:gt+, +:gte+, +:lt+, +:lte+, or +:in+.
37
+ # +:in+ requires an Array. Values are detached on construction.
31
38
  def compare(field, operator, value)
32
39
  raise ConfigurationError, "Unsupported query operator" unless OPERATORS.include?(operator)
33
40
  raise ConfigurationError, "IN requires an array" if operator == :in && !value.is_a?(Array)
@@ -35,15 +42,18 @@ module ObjectFramework
35
42
  copy(criteria: criteria + [criterion])
36
43
  end
37
44
 
45
+ # Appends an ascending or descending ordering using +:asc+ or +:desc+.
38
46
  def order(field, direction = :asc)
39
47
  raise ConfigurationError, "Invalid ordering direction" unless %i[asc desc].include?(direction)
40
48
  copy(ordering: ordering + [[self.class.identifier(field), direction].freeze])
41
49
  end
42
50
 
51
+ # Returns a query limited to a nonnegative integer number of rows.
43
52
  def limit(count)
44
53
  copy(row_limit: nonnegative(count))
45
54
  end
46
55
 
56
+ # Returns a query skipping a nonnegative integer number of rows.
47
57
  def offset(count)
48
58
  copy(row_offset: nonnegative(count))
49
59
  end
@@ -65,6 +65,9 @@ module ObjectFramework
65
65
  def clear; @entries.clear; end
66
66
  end
67
67
 
68
+ # Owns an identity map and unit of work for one active transaction.
69
+ # Managed entities stay inside the invocation; return independent values
70
+ # to callers. Application#invoke flushes and closes its session automatically.
68
71
  class Session
69
72
  attr_reader :transaction
70
73
 
@@ -74,6 +77,9 @@ module ObjectFramework
74
77
  @closed, @removed = false, {}
75
78
  end
76
79
 
80
+ # Tracks a new mapped entity, assigning a UUID when its identity is nil.
81
+ # Returns the entity. Duplicate identities and previously removed
82
+ # entities cannot be registered as new objects in this session.
77
83
  def add(entity)
78
84
  active!
79
85
  mapping = @mappings.fetch(entity.class)
@@ -88,6 +94,9 @@ module ObjectFramework
88
94
  entity
89
95
  end
90
96
 
97
+ # Finds a mapped entity by identity or raises NotFound. Repeated lookups
98
+ # return the same object. +load+ explicitly selects associations, with
99
+ # nested hashes for nested loading; no implicit lazy loading occurs.
91
100
  def find(type, id, load: [])
92
101
  active!
93
102
  raise NotFound, "Entity was removed" if @removed.key?([type, id])
@@ -102,6 +111,8 @@ module ObjectFramework
102
111
  entity
103
112
  end
104
113
 
114
+ # Returns managed entities matching an immutable Query and optional
115
+ # association loading plan. Removed identities are excluded.
105
116
  def query(type, query = Query.new, load: [])
106
117
  active!
107
118
  mapping = @mappings.fetch(type)
@@ -112,6 +123,7 @@ module ObjectFramework
112
123
  end
113
124
  end
114
125
 
126
+ # Schedules deletion of a managed entity, or discards a pending insert.
115
127
  def remove(entity)
116
128
  active!
117
129
  id = @work.entry(entity).identity
@@ -120,6 +132,8 @@ module ObjectFramework
120
132
  @identities.remove(entity.class, id)
121
133
  end
122
134
 
135
+ # Writes tracked inserts, changes, and deletions without committing.
136
+ # Managed identities cannot change. Requires an active session.
123
137
  def flush
124
138
  active!
125
139
  @work.flush
@@ -5,6 +5,9 @@ module ObjectFramework
5
5
  class ClosedTransaction < StandardError; end
6
6
  class ReadOnlyTransaction < StandardError; end
7
7
 
8
+ # Base lifecycle for read/write storage transactions. Adapters implement
9
+ # row access, commit, and storage cleanup. #close rolls back an active
10
+ # transaction and is safe to repeat. Committed transactions cannot be reused.
8
11
  class Transaction
9
12
  attr_reader :state, :mode
10
13
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
+ # Ruby descriptors and wire encoding for proto3 messages and enums.
4
5
  module Protobuf
5
6
  SCALARS = {
6
7
  int32: 0, int64: 0, uint32: 0, uint64: 0, sint32: 0, sint64: 0, bool: 0,
@@ -59,6 +60,8 @@ module ObjectFramework
59
60
  end
60
61
  end
61
62
 
63
+ # Immutable proto3 enum declaration. Its first value must be zero, and
64
+ # names and numeric values must be unique and valid for proto3.
62
65
  class Enum
63
66
  attr_reader :name, :values
64
67
  def initialize(name, **values)
@@ -76,6 +79,14 @@ module ObjectFramework
76
79
 
77
80
  Field = Data.define(:name, :number, :type, :repeated, :packed)
78
81
 
82
+ # Immutable message descriptor encoding and decoding ordinary value hashes.
83
+ # Declare fields in the construction block.
84
+ #
85
+ # message = ObjectFramework::Protobuf::Message.new("Greeting") do
86
+ # field :text, 1, :string
87
+ # end
88
+ # bytes = message.encode(text: "Hello")
89
+ # message.decode(bytes) # => { text: "Hello" }
79
90
  class Message
80
91
  attr_reader :name, :fields
81
92
 
@@ -87,6 +98,9 @@ module ObjectFramework
87
98
  freeze
88
99
  end
89
100
 
101
+ # Declares a unique field name and proto3 number. +type+ is a scalar in
102
+ # SCALARS, another Message, or an Enum. Repeated numeric fields use packed
103
+ # encoding by default. Declare fields only during construction.
90
104
  def field(name, number, type, repeated: false, packed: true)
91
105
  name = Protobuf.name(name).to_sym
92
106
  unless number.is_a?(Integer) && number.between?(1, 2**29 - 1) && !number.between?(19_000, 19_999)
@@ -97,6 +111,9 @@ module ObjectFramework
97
111
  @fields << Field.new(name:, number:, type:, repeated:, packed:)
98
112
  end
99
113
 
114
+ # Encodes a Hash or Data value, or keyword attributes, as binary proto3.
115
+ # Raises InvalidInput for unknown fields, invalid values, or excessive
116
+ # nesting. Repeated fields require arrays.
100
117
  def encode(values = nil, depth: 0, **attributes)
101
118
  raise InvalidInput, "Protobuf nesting limit exceeded" if depth >= 64
102
119
  raise InvalidInput, "Pass message values once" if values && !attributes.empty?
@@ -122,6 +139,8 @@ module ObjectFramework
122
139
  end
123
140
  end
124
141
 
142
+ # Decodes binary proto3 into a Hash, supplying defaults for absent fields
143
+ # and skipping unknown fields. Malformed input raises InvalidInput.
125
144
  def decode(bytes, depth: 0, initial: nil)
126
145
  raise InvalidInput, "Protobuf nesting limit exceeded" if depth >= 64
127
146
  cursor = Wire::Cursor.new(bytes)
@@ -5,10 +5,15 @@ module ObjectFramework
5
5
  ForeignKey = Data.define(:table, :column, :target)
6
6
  Migration = Data.define(:version, :actions)
7
7
 
8
+ # Declared SQLite tables, foreign keys, and consecutive schema migrations.
9
+ # Version 1 creates the initial tables. Storage finalizes this declaration
10
+ # and applies pending migrations transactionally during construction.
8
11
  class Schema
9
12
  TYPES = { string: "TEXT", integer: "INTEGER", float: "REAL", boolean: "INTEGER", blob: "BLOB" }.freeze
10
13
  attr_reader :tables, :foreign_keys, :migrations
11
14
 
15
+ # Validates domain mappings and derives tables and belongs-to foreign
16
+ # keys. Returns a schema that can still receive migration declarations.
12
17
  def self.from_mappings(mappings)
13
18
  mappings.validate!
14
19
  keys = mappings.flat_map do |mapping|
@@ -30,6 +35,10 @@ module ObjectFramework
30
35
  @migrations = [Migration.new(version: 1, actions: [].freeze)]
31
36
  end
32
37
 
38
+ # Declares the next consecutive version using Editor#add_column.
39
+ # Declare migrations before constructing Storage. Returns +self+.
40
+ #
41
+ # schema.migration(2) { add_column :orders, :note, type: :string }
33
42
  def migration(version, &declaration)
34
43
  raise ConfigurationError, "Schema is finalized" if frozen?
35
44
  raise ConfigurationError, "Migration versions must be consecutive" unless version == @migrations.last.version + 1
@@ -21,9 +21,16 @@ module ObjectFramework
21
21
  end
22
22
  end
23
23
 
24
+ # Optional file-backed SQLite adapter, requiring the sqlite3 gem.
25
+ # Each transaction owns a connection with foreign keys enabled and FULL
26
+ # synchronization. Uses DELETE journaling, IMMEDIATE writes, and DEFERRED
27
+ # reads. Commit failures are propagated without automatic retry.
24
28
  class Storage
25
29
  attr_reader :schema_version
26
30
 
31
+ # Finalizes +schema+ and migrates the database at +path+. In-memory paths
32
+ # are rejected. +lock_timeout+ bounds lock waiting in seconds; execution
33
+ # cancellation and deadlines may interrupt that wait earlier.
27
34
  def initialize(path:, schema:, lock_timeout: 1)
28
35
  raise ConfigurationError, "SQLite requires a database file path" if path.to_s.empty? || path.to_s == ":memory:"
29
36
  raise ConfigurationError, "Lock timeout must be positive" unless lock_timeout.is_a?(Numeric) && lock_timeout.positive?
@@ -52,6 +59,8 @@ module ObjectFramework
52
59
  raise
53
60
  end
54
61
 
62
+ # Rejects new transactions. Already-open transactions retain ownership
63
+ # of their connections until they close. Stop applications first.
55
64
  def close
56
65
  @mutex.synchronize { @closed = true }
57
66
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObjectFramework
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -25,6 +25,11 @@ require_relative "object_framework/grpc/messages"
25
25
  require_relative "object_framework/http2/connection"
26
26
  require_relative "object_framework/grpc/server"
27
27
 
28
+ # Applications composed from ordinary Ruby objects, with explicit dependencies,
29
+ # invocation-scoped resources, and transport-independent use cases.
30
+ #
31
+ # Application is the composition root. Persistence, HTTP, GRPC, and Protobuf
32
+ # supply adapters and contracts; SQLite and Capabilities load on demand.
28
33
  module ObjectFramework
29
34
  autoload :SQLite, File.expand_path("object_framework/sqlite", __dir__)
30
35
  autoload :Capabilities, File.expand_path("object_framework/capabilities", __dir__)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-object-framework
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gastón Caruso
@@ -35,6 +35,7 @@ files:
35
35
  - examples/guides/11_ractor.rb
36
36
  - examples/guides/12_box.rb
37
37
  - examples/guides/13_testing.rb
38
+ - examples/guides/14_request_beans.rb
38
39
  - examples/guides/manifest.yml
39
40
  - examples/orders/application.rb
40
41
  - examples/orders/contracts.rb