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 +4 -4
- data/API.md +12 -3
- data/README.md +24 -2
- data/THIRD_PARTY_NOTICES.md +9 -0
- data/examples/guides/14_request_beans.rb +46 -0
- data/examples/guides/manifest.yml +7 -0
- data/lib/object_framework/application.rb +64 -1
- data/lib/object_framework/capabilities.rb +13 -0
- data/lib/object_framework/errors.rb +14 -0
- data/lib/object_framework/execution.rb +17 -0
- data/lib/object_framework/grpc/server.rb +9 -0
- data/lib/object_framework/grpc/service.rb +9 -0
- data/lib/object_framework/http/presentation.rb +4 -0
- data/lib/object_framework/http/router.rb +13 -0
- data/lib/object_framework/http/server.rb +8 -0
- data/lib/object_framework/memory/storage.rb +7 -0
- data/lib/object_framework/persistence/mapping.rb +44 -0
- data/lib/object_framework/persistence/query.rb +10 -0
- data/lib/object_framework/persistence/session.rb +14 -0
- data/lib/object_framework/persistence/transaction.rb +3 -0
- data/lib/object_framework/protobuf.rb +19 -0
- data/lib/object_framework/sqlite/schema.rb +9 -0
- data/lib/object_framework/sqlite/storage.rb +9 -0
- data/lib/object_framework/version.rb +1 -1
- data/lib/object_framework.rb +5 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce4bd20189a52219bc14f74365a6266ea8a86c6a4ff75a8893357c5405ec71ab
|
|
4
|
+
data.tar.gz: 5f3b4b8cc2d8acc3396a5bb3d82e859439ce6fe29f5fdb5f50499ca7d00ccfcf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
12
|
-
`operation`, and finalize the configuration with `start`. Always call
|
|
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.
|
|
97
|
-
app.operation(:greet, use_case: Greet, policy: :none
|
|
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
|
|
data/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
data/lib/object_framework.rb
CHANGED
|
@@ -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.
|
|
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
|