cordis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +202 -0
- data/README.md +201 -0
- data/lib/cordis/context.rb +186 -0
- data/lib/cordis/events.rb +92 -0
- data/lib/cordis/fiber.rb +276 -0
- data/lib/cordis/registry.rb +71 -0
- data/lib/cordis/service.rb +49 -0
- data/lib/cordis/version.rb +5 -0
- data/lib/cordis.rb +28 -0
- metadata +69 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 390ce9e78dbb7edfacb5cd004d46448eadd6c6ccffad5eaee9ef190c2b9a15a2
|
|
4
|
+
data.tar.gz: af955c8b180e217685481a3da9d27d9ed56bb95ce1b74c49f7f42296f1469876
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 25e46a5eff3a9e977fbcaf1ccddf72ae83a3b0980d9f00aa24afac3a188fb2a284eb822955d15c64bea68b547972a552d2267b6a8c92560d28a0d72d57cc47a4
|
|
7
|
+
data.tar.gz: 95e3fa579b1a1c743aac12a4c7649b45e27df8f1882b0cb3c6ce9f7a96cd55aa052732ca12baeea66f559c02c9de8fbb0667cfd566a19fa5baad8a236505ffd8
|
data/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# cordis-rb
|
|
2
|
+
|
|
3
|
+
[English](#english) | [繁體中文](#繁體中文) | [日本語](#日本語)
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## English
|
|
8
|
+
|
|
9
|
+
> ⚠️ **Experimental** — a learning/research project, not production-ready. APIs change without notice.
|
|
10
|
+
|
|
11
|
+
cordis-rb is a Ruby reimplementation of the core mechanisms of [Cordis](https://github.com/cordiverse/cordis) (a TypeScript plugin/lifecycle meta-framework), based on the paper *"A Programming Paradigm for Spatiotemporal Composability"*. It is built on the [async](https://github.com/socketry/async) gem, using Fiber-based cooperative concurrency in place of the event loop that JavaScript gets for free.
|
|
12
|
+
|
|
13
|
+
### What's here so far
|
|
14
|
+
|
|
15
|
+
Aligned with upstream `packages/core` (4.0 naming: `Fiber`, formerly `EffectScope`):
|
|
16
|
+
|
|
17
|
+
- **Revertible effects** — `ctx.effect` applies a side effect and registers its inverse (single or multi-step); disposal is strict LIFO (Theorem 16)
|
|
18
|
+
- **Plugin system** — `ctx.plugin` applies a plugin *as a revertible effect* on the parent fiber, so the whole plugin tree is one nested effect tree; loading is deferred one tick, load/unload transitions are serialized per fiber (inertia lock)
|
|
19
|
+
- **Reactive coeffects** — `ctx.provide` / `ctx.inject`: consumers load when their dependencies are satisfied, reload when a provider is swapped, and are torn down *before* their provider finishes unloading
|
|
20
|
+
- **Events** — `ctx.on` / `once` / `emit` / `bail` / `waterfall` (sync) and `ctx.parallel` / `serial` (async); listeners are effects, removed automatically on fiber disposal
|
|
21
|
+
- **Isolation & intercept** — `ctx.isolate` puts a service name in its own realm (provides/injects no longer cross the boundary; share a realm by passing the same label); `ctx.intercept` / Hash inject configs carry per-caller config, resolved with `ctx.resolve_config`
|
|
22
|
+
- **Service base class** — subclass `Cordis::Service`, declare `provide :name` (+ optional `inject`), and pass the class to `ctx.plugin`: the instance provides itself, and `#init` runs as the load body (blocking there keeps the service invisible to dependents — pending inject)
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
ctx = Cordis::Context.new
|
|
26
|
+
|
|
27
|
+
Sync do
|
|
28
|
+
provider = ctx.plugin(lambda { |c, _config|
|
|
29
|
+
db = Database.connect
|
|
30
|
+
c.effect { -> { db.close } } # registered before provide → runs after all dependents are gone
|
|
31
|
+
c.provide(:db, db)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
ctx.inject([:db]) do |c, _config|
|
|
35
|
+
c.on('request') { |req| c.db.query(req) } # loads only once :db is active
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
provider.await # wait for the deferred load
|
|
39
|
+
provider.dispose # dependents tear down first, then the provider, LIFO
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Roadmap
|
|
44
|
+
|
|
45
|
+
- [x] Revertible effects with LIFO disposal
|
|
46
|
+
- [x] Plugin/fiber lifecycle (epoch + inertia state machine, via `async`)
|
|
47
|
+
- [x] Reactive coeffects (`ctx.provide` / `ctx.inject`)
|
|
48
|
+
- [x] Event system (`ctx.on`, waterfall included)
|
|
49
|
+
- [x] Isolation & intercept (`ctx.isolate` / `ctx.intercept`)
|
|
50
|
+
- [x] `Cordis::Service` base class
|
|
51
|
+
- [ ] Loader / hot-reload reconciliation — maybe, later
|
|
52
|
+
|
|
53
|
+
### Installation
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
gem 'cordis', github: '5xRuby/cordis-rb' # not yet published to rubygems.org
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Development
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
bundle install && bundle exec rspec
|
|
63
|
+
bundle exec ruby examples/demo.rb # lifecycle / coeffect walkthrough
|
|
64
|
+
ruby examples/webapp.rb # the same story with Sinatra + Falcon + SQLite
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### License
|
|
68
|
+
|
|
69
|
+
[Apache-2.0](LICENSE)
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 繁體中文
|
|
74
|
+
|
|
75
|
+
> ⚠️ **實驗性專案** — 練習/研究用途,不求生產可用,API 隨時會變。
|
|
76
|
+
|
|
77
|
+
cordis-rb 用 Ruby 重新實作 [Cordis](https://github.com/cordiverse/cordis)(TypeScript 的 plugin/生命週期 meta-framework)的核心機制,理論基礎來自論文 *"A Programming Paradigm for Spatiotemporal Composability"*。以 [async](https://github.com/socketry/async) gem 為併發基底 —— JavaScript 有 event loop 免費提供協作式併發,Ruby 沒有對應物,這裡用 Fiber-based 的 async 補上這塊。
|
|
78
|
+
|
|
79
|
+
### 目前完成
|
|
80
|
+
|
|
81
|
+
對接上游 `packages/core`(4.0 命名:`Fiber`,即原本的 `EffectScope`):
|
|
82
|
+
|
|
83
|
+
- **Revertible effect** — `ctx.effect` 套用副作用並登記 inverse(單步或多步);撤除嚴格 LIFO(Theorem 16)
|
|
84
|
+
- **Plugin 系統** — `ctx.plugin` 把 plugin *當成一個 revertible effect* 掛在 parent fiber 上,整棵 plugin tree 就是巢狀 effect tree;載入延後一個 tick,每個 fiber 的 load/unload 由 inertia lock 序列化
|
|
85
|
+
- **Reactive coeffect** — `ctx.provide` / `ctx.inject`:依賴滿足才載入、provider 被換掉就 reload、provider 卸載前依賴者先 teardown
|
|
86
|
+
- **事件系統** — `ctx.on` / `once` / `emit` / `bail` / `waterfall`(同步)與 `ctx.parallel` / `serial`(非同步);listener 就是 effect,fiber 卸載時自動移除
|
|
87
|
+
- **Isolation 與 intercept** — `ctx.isolate` 讓某個 service name 進入獨立 realm(provide/inject 不再跨界;傳同一個 label 可共用 realm);`ctx.intercept` 與 Hash 形式的 inject config 攜帶 per-caller 設定,用 `ctx.resolve_config` 解析
|
|
88
|
+
- **Service base class** — 繼承 `Cordis::Service`、宣告 `provide :name`(可加 `inject`),把 class 直接丟給 `ctx.plugin`:instance 會 provide 自己,`#init` 就是載入本體(在裡面 block 住,service 對依賴者就不可見 —— pending inject)
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
ctx = Cordis::Context.new
|
|
92
|
+
|
|
93
|
+
Sync do
|
|
94
|
+
provider = ctx.plugin(lambda { |c, _config|
|
|
95
|
+
db = Database.connect
|
|
96
|
+
c.effect { -> { db.close } } # 註冊在 provide 之前 → 所有依賴者卸載後才執行
|
|
97
|
+
c.provide(:db, db)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
ctx.inject([:db]) do |c, _config|
|
|
101
|
+
c.on('request') { |req| c.db.query(req) } # :db active 之後才載入
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
provider.await # 等延後的載入完成
|
|
105
|
+
provider.dispose # 依賴者先卸載,再撤 provider,LIFO
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Roadmap
|
|
110
|
+
|
|
111
|
+
- [x] Revertible effect 與 LIFO 撤除
|
|
112
|
+
- [x] Plugin/fiber 生命週期(epoch + inertia 狀態機,基於 `async`)
|
|
113
|
+
- [x] Reactive coeffect(`ctx.provide` / `ctx.inject`)
|
|
114
|
+
- [x] 事件系統(`ctx.on`,含 waterfall)
|
|
115
|
+
- [x] Isolation 與 intercept(`ctx.isolate` / `ctx.intercept`)
|
|
116
|
+
- [x] `Cordis::Service` base class
|
|
117
|
+
- [ ] Loader / hot-reload reconciliation — 骨架穩了再說
|
|
118
|
+
|
|
119
|
+
### 安裝
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
gem 'cordis', github: '5xRuby/cordis-rb' # 尚未發佈到 rubygems.org
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 開發
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
bundle install && bundle exec rspec
|
|
129
|
+
bundle exec ruby examples/demo.rb # 生命週期 / coeffect 導覽
|
|
130
|
+
ruby examples/webapp.rb # 同一個故事,換成 Sinatra + Falcon + SQLite
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 授權
|
|
134
|
+
|
|
135
|
+
[Apache-2.0](LICENSE)
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 日本語
|
|
140
|
+
|
|
141
|
+
> ⚠️ **実験的プロジェクト** — 学習・研究目的であり、プロダクション利用は想定していません。API は予告なく変わります。
|
|
142
|
+
|
|
143
|
+
cordis-rb は、[Cordis](https://github.com/cordiverse/cordis)(TypeScript 製の plugin / ライフサイクル meta-framework)のコア機構を Ruby で再実装するプロジェクトです。理論的基盤は論文 *"A Programming Paradigm for Spatiotemporal Composability"* にあります。並行処理の基盤には [async](https://github.com/socketry/async) gem を採用 —— JavaScript ではイベントループが協調的並行性を無償で提供しますが、Ruby には相当物がないため、Fiber ベースの async でその役割を担います。
|
|
144
|
+
|
|
145
|
+
### 現状
|
|
146
|
+
|
|
147
|
+
上流 `packages/core` に整合(4.0 命名:`Fiber`、旧 `EffectScope`):
|
|
148
|
+
|
|
149
|
+
- **Revertible effect** — `ctx.effect` が副作用を適用して inverse を登録(単段・多段);巻き戻しは厳密な LIFO(Theorem 16)
|
|
150
|
+
- **Plugin システム** — `ctx.plugin` は plugin を *revertible effect として* parent fiber に掛けるため、plugin ツリー全体が入れ子の effect ツリーになる;ロードは 1 tick 遅延、fiber ごとの load/unload は inertia lock で直列化
|
|
151
|
+
- **Reactive coeffect** — `ctx.provide` / `ctx.inject`:依存が満たされたらロード、provider が入れ替われば reload、provider のアンロード前に依存側が先に teardown
|
|
152
|
+
- **イベントシステム** — `ctx.on` / `once` / `emit` / `bail` / `waterfall`(同期)と `ctx.parallel` / `serial`(非同期);listener は effect であり、fiber の破棄時に自動で外れる
|
|
153
|
+
- **Isolation と intercept** — `ctx.isolate` は service name を独立した realm に隔離(provide/inject は境界を越えない;同じ label を渡せば realm を共有);`ctx.intercept` と Hash 形式の inject config は per-caller 設定を運び、`ctx.resolve_config` で解決
|
|
154
|
+
- **Service base class** — `Cordis::Service` を継承し `provide :name`(必要なら `inject` も)を宣言、class をそのまま `ctx.plugin` に渡す:instance が自身を provide し、`#init` がロード本体になる(そこで block すれば service は依存側から不可視のまま —— pending inject)
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
ctx = Cordis::Context.new
|
|
158
|
+
|
|
159
|
+
Sync do
|
|
160
|
+
provider = ctx.plugin(lambda { |c, _config|
|
|
161
|
+
db = Database.connect
|
|
162
|
+
c.effect { -> { db.close } } # provide より先に登録 → 依存側が全て消えた後に実行
|
|
163
|
+
c.provide(:db, db)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
ctx.inject([:db]) do |c, _config|
|
|
167
|
+
c.on('request') { |req| c.db.query(req) } # :db が active になってからロード
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
provider.await # 遅延ロードの完了を待つ
|
|
171
|
+
provider.dispose # 依存側が先に teardown、その後 provider、LIFO
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Roadmap
|
|
176
|
+
|
|
177
|
+
- [x] Revertible effect と LIFO 巻き戻し
|
|
178
|
+
- [x] Plugin/fiber ライフサイクル(epoch + inertia ステートマシン、`async` ベース)
|
|
179
|
+
- [x] Reactive coeffect(`ctx.provide` / `ctx.inject`)
|
|
180
|
+
- [x] イベントシステム(`ctx.on`、waterfall 含む)
|
|
181
|
+
- [x] Isolation と intercept(`ctx.isolate` / `ctx.intercept`)
|
|
182
|
+
- [x] `Cordis::Service` base class
|
|
183
|
+
- [ ] Loader / hot-reload reconciliation — 骨格が安定してから
|
|
184
|
+
|
|
185
|
+
### インストール
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
gem 'cordis', github: '5xRuby/cordis-rb' # rubygems.org には未公開
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### 開発
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
bundle install && bundle exec rspec
|
|
195
|
+
bundle exec ruby examples/demo.rb # ライフサイクル / coeffect ウォークスルー
|
|
196
|
+
ruby examples/webapp.rb # 同じストーリーを Sinatra + Falcon + SQLite で
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### ライセンス
|
|
200
|
+
|
|
201
|
+
[Apache-2.0](LICENSE)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cordis
|
|
4
|
+
# Thin shell: root bootstrap, service access (the paper's coeffect context), and
|
|
5
|
+
# delegation to the plugin/event entry points.
|
|
6
|
+
# Breaking change (round 2): extend now matches upstream semantics — it creates a
|
|
7
|
+
# "same root, different fiber" view of the ctx, not a new disposal scope; new scopes
|
|
8
|
+
# always come from ctx.plugin.
|
|
9
|
+
class Context
|
|
10
|
+
Impl = Struct.new(:name, :fiber, :value)
|
|
11
|
+
|
|
12
|
+
attr_reader :root, :registry, :events, :fiber, :services
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@root = self
|
|
16
|
+
@services = {} # isolate key => Impl (default key is the name itself)
|
|
17
|
+
@isolate = {} # name => isolation label; absent = the shared default realm
|
|
18
|
+
@intercept = {} # name => per-caller config (copy-on-write, chain pre-merged)
|
|
19
|
+
@fiber = Fiber.new(self) # root fiber: always active, dispose = restart
|
|
20
|
+
@registry = Registry.new(self)
|
|
21
|
+
@events = Events.new(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Internal: a ctx view bound to another fiber (sharing root/registry/events/services).
|
|
25
|
+
def extend(fiber: @fiber)
|
|
26
|
+
ctx = dup
|
|
27
|
+
ctx.instance_variable_set(:@fiber, fiber)
|
|
28
|
+
ctx
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A ctx view where service `name` lives in its own realm: provides/injects through
|
|
32
|
+
# this view no longer see (or are seen by) the default realm. Pass the same label
|
|
33
|
+
# to two isolate calls to share one realm between them.
|
|
34
|
+
def isolate(name, label = nil)
|
|
35
|
+
ctx = extend
|
|
36
|
+
ctx.instance_variable_set(:@isolate, @isolate.merge(name.to_sym => label || Object.new))
|
|
37
|
+
ctx
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A ctx view carrying per-caller config for service `name` (upstream ctx.intercept).
|
|
41
|
+
# Nested intercepts merge, inner overriding outer (Hash configs only; anything else replaces).
|
|
42
|
+
def intercept(name, config)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
old = @intercept[name]
|
|
45
|
+
merged = old.is_a?(Hash) && config.is_a?(Hash) ? old.merge(config) : config
|
|
46
|
+
ctx = extend
|
|
47
|
+
ctx.instance_variable_set(:@intercept, @intercept.merge(name => merged))
|
|
48
|
+
ctx
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Effective intercept config for `name` as seen from this ctx, merged over `base`
|
|
52
|
+
# (upstream Service[resolveConfig], minus the Config schema merge).
|
|
53
|
+
def resolve_config(name, base = nil)
|
|
54
|
+
config = @intercept[name.to_sym]
|
|
55
|
+
return base if config.nil?
|
|
56
|
+
|
|
57
|
+
base.is_a?(Hash) && config.is_a?(Hash) ? base.merge(config) : config
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The store key for `name` in this ctx's realm.
|
|
61
|
+
def isolate_key(name) = @isolate[name] || name
|
|
62
|
+
|
|
63
|
+
# -- effect / plugin --
|
|
64
|
+
|
|
65
|
+
def effect(label = nil, &) = @fiber.effect(label, &)
|
|
66
|
+
def plugin(plugin, config = nil) = @root.registry.plugin(self, plugin, config)
|
|
67
|
+
|
|
68
|
+
def inject(deps, &block)
|
|
69
|
+
plugin({ inject: deps, apply: block, name: 'inject' })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# -- events --
|
|
73
|
+
|
|
74
|
+
def on(name, prepend: false, &) = @root.events.register(self, name, prepend: prepend, &)
|
|
75
|
+
def once(name, prepend: false, &) = @root.events.once(self, name, prepend: prepend, &)
|
|
76
|
+
def emit(name, *) = @root.events.emit(name, *)
|
|
77
|
+
def bail(name, *) = @root.events.bail(name, *)
|
|
78
|
+
def serial(name, *) = @root.events.serial(name, *)
|
|
79
|
+
def parallel(name, *) = @root.events.parallel(name, *)
|
|
80
|
+
def waterfall(name, *, &) = @root.events.waterfall(name, *, &)
|
|
81
|
+
|
|
82
|
+
# -- service(reactive coeffect)--
|
|
83
|
+
|
|
84
|
+
# Providing a service is itself a revertible effect.
|
|
85
|
+
# Teardown ordering guarantee: remove from the store -> notify -> wait for every
|
|
86
|
+
# dependent to finish unloading -> only then delete the provider's self-visible entry
|
|
87
|
+
# (so dependents' disposers can still reach the service during their own teardown).
|
|
88
|
+
def provide(name, value = nil)
|
|
89
|
+
name = name.to_sym
|
|
90
|
+
key = isolate_key(name)
|
|
91
|
+
owner = @fiber
|
|
92
|
+
@fiber.effect("ctx.provide(#{name.inspect})") do
|
|
93
|
+
raise ServiceError, "service #{name.inspect} has already been registered" if @root.services.key?(key)
|
|
94
|
+
|
|
95
|
+
impl = Impl.new(name, owner, value)
|
|
96
|
+
@root.services[key] = impl
|
|
97
|
+
owner.store[name] = impl # immediately visible to the provider itself
|
|
98
|
+
owner.provided[name] = key
|
|
99
|
+
@root.notify({ name => key }) if owner.state == :active
|
|
100
|
+
lambda do
|
|
101
|
+
@root.services.delete(key)
|
|
102
|
+
dependents = @root.notify({ name => key })
|
|
103
|
+
dependents.each do |dep|
|
|
104
|
+
dep.await
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil # allSettled semantics: one dependent failing to unload never blocks the rest
|
|
107
|
+
end
|
|
108
|
+
owner.store&.delete(name)
|
|
109
|
+
owner.provided.delete(name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Strict: the provider fiber must be ACTIVE to be visible (a service whose provider
|
|
115
|
+
# is still loading is invisible to dependents).
|
|
116
|
+
def get(name)
|
|
117
|
+
impl = @root.services[isolate_key(name.to_sym)]
|
|
118
|
+
return nil unless impl && impl.fiber.state == :active
|
|
119
|
+
|
|
120
|
+
impl.value
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def set(name, value)
|
|
124
|
+
name = name.to_sym
|
|
125
|
+
impl = @root.services[isolate_key(name)]
|
|
126
|
+
raise ServiceError, %(cannot set property "#{name}" without provide) unless impl
|
|
127
|
+
raise ServiceError, %(cannot set property "#{name}" in multiple fibers) unless impl.fiber.equal?(@fiber)
|
|
128
|
+
|
|
129
|
+
impl.value = value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Dependency-graph update (fully synchronous): linear scan over all fibers,
|
|
133
|
+
# re-checking satisfaction and recomputing epochs. Only fibers whose ctx resolves
|
|
134
|
+
# the name to the same isolate key are touched. Takes { name => key } pairs and
|
|
135
|
+
# returns the touched fibers (provide's teardown uses the list to wait for dependents).
|
|
136
|
+
def notify(pairs)
|
|
137
|
+
touched = []
|
|
138
|
+
@registry.runtimes.each do |runtime|
|
|
139
|
+
runtime.fibers.each do |fib|
|
|
140
|
+
hits = pairs.select { |n, key| fib.inject.key?(n) && fib.ctx.isolate_key(n) == key }
|
|
141
|
+
next if hits.empty?
|
|
142
|
+
|
|
143
|
+
hits.each_key { |n| fib.check_impl(n) }
|
|
144
|
+
fib.refresh
|
|
145
|
+
touched << fib
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
pairs.each_key { |n| @events.emit('internal/service', n) }
|
|
149
|
+
touched
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Service access: walk the fiber parent chain looking at snapshots
|
|
153
|
+
# (error messages match upstream).
|
|
154
|
+
def method_missing(name, *args, &block)
|
|
155
|
+
return super unless args.empty? && block.nil?
|
|
156
|
+
|
|
157
|
+
resolve_service(name)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def respond_to_missing?(name, include_private = false)
|
|
161
|
+
@root.services.key?(isolate_key(name)) || super
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def resolve_service(name)
|
|
167
|
+
key = isolate_key(name)
|
|
168
|
+
fib = @fiber
|
|
169
|
+
loop do
|
|
170
|
+
if fib.store
|
|
171
|
+
impl = fib.store[name]
|
|
172
|
+
# a service this fiber *provides* is only visible from the same realm
|
|
173
|
+
# (stricter than upstream, whose walk is name-keyed at the terminal fiber)
|
|
174
|
+
return impl.value if impl && (!fib.provided.key?(name) || fib.provided[name] == key)
|
|
175
|
+
end
|
|
176
|
+
raise ServiceError, %(cannot get required service "#{name}" in inactive context) if fib.inject.key?(name)
|
|
177
|
+
# stop at root, or when walking up would cross an isolation boundary
|
|
178
|
+
if fib.root? || fib.parent_ctx.isolate_key(name) != key
|
|
179
|
+
raise ServiceError, %(cannot get property "#{name}" without inject)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
fib = fib.parent_fiber
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
|
|
5
|
+
module Cordis
|
|
6
|
+
# Event system (upstream 4.0's EventsService): registering a listener is itself a
|
|
7
|
+
# fiber effect, so listeners are removed automatically when the fiber unloads.
|
|
8
|
+
# emit/bail/waterfall are synchronous; parallel requires a reactor.
|
|
9
|
+
class Events
|
|
10
|
+
Hook = Struct.new(:ctx, :callback)
|
|
11
|
+
|
|
12
|
+
attr_reader :hooks
|
|
13
|
+
|
|
14
|
+
def initialize(ctx)
|
|
15
|
+
@ctx = ctx
|
|
16
|
+
@hooks = Hash.new { |hash, key| hash[key] = [] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register(ctx, name, prepend: false, &callback)
|
|
20
|
+
list = @hooks[name]
|
|
21
|
+
hook = Hook.new(ctx, callback)
|
|
22
|
+
ctx.fiber.effect("ctx.on(#{name.inspect})") do
|
|
23
|
+
prepend ? list.unshift(hook) : list.push(hook)
|
|
24
|
+
-> { list.reject! { |h| h.equal?(hook) } }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def once(ctx, name, prepend: false, &callback)
|
|
29
|
+
dispose = register(ctx, name, prepend: prepend) do |*args|
|
|
30
|
+
dispose.call
|
|
31
|
+
callback.call(*args)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Synchronous fan-out; return values ignored; listener exceptions propagate (matches upstream).
|
|
36
|
+
def emit(name, *)
|
|
37
|
+
@hooks[name].dup.each { |hook| hook.callback.call(*) }
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Sequential calls; returns the first non-nil/false result.
|
|
42
|
+
# serial is the same semantics named for async listeners: Ruby async is blocking-style,
|
|
43
|
+
# a listener just blocks, so the two share one implementation.
|
|
44
|
+
def bail(name, *)
|
|
45
|
+
@hooks[name].dup.each do |hook|
|
|
46
|
+
result = hook.callback.call(*)
|
|
47
|
+
return result if bailed?(result)
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
alias serial bail
|
|
52
|
+
|
|
53
|
+
# Middleware chain: each listener receives an extra next (callable); not calling
|
|
54
|
+
# next stops the chain; the fallback block runs only when every listener passed through.
|
|
55
|
+
def waterfall(name, *, &fallback)
|
|
56
|
+
chain = @hooks[name].dup
|
|
57
|
+
index = -1
|
|
58
|
+
step = nil
|
|
59
|
+
step = lambda do |*current|
|
|
60
|
+
index += 1
|
|
61
|
+
if index < chain.size
|
|
62
|
+
chain[index].callback.call(*current, step)
|
|
63
|
+
elsif fallback
|
|
64
|
+
fallback.call(*current)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
step.call(*)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Call all listeners concurrently without short-circuiting: exceptions are collected
|
|
71
|
+
# into an AggregateError after everything ran (requires Sync/Async).
|
|
72
|
+
def parallel(name, *)
|
|
73
|
+
parent = Async::Task.current
|
|
74
|
+
tasks = @hooks[name].dup.map do |hook|
|
|
75
|
+
parent.async { hook.callback.call(*) }
|
|
76
|
+
end
|
|
77
|
+
errors = []
|
|
78
|
+
tasks.each do |task|
|
|
79
|
+
task.wait
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
errors << e
|
|
82
|
+
end
|
|
83
|
+
raise AggregateError, errors unless errors.empty?
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def bailed?(result) = !result.nil? && result != false
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/cordis/fiber.rb
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
|
|
5
|
+
module Cordis
|
|
6
|
+
# Fiber (upstream 4.0's new name for EffectScope): the lifecycle unit of a plugin,
|
|
7
|
+
# and also the collector of revertible effects (the paper's phi accumulator).
|
|
8
|
+
# Note: the clash with Ruby core's ::Fiber is deliberate, to match upstream naming;
|
|
9
|
+
# Cordis never uses core Fiber directly.
|
|
10
|
+
class Fiber
|
|
11
|
+
INACTIVE = :__inactive__
|
|
12
|
+
|
|
13
|
+
Entry = Struct.new(:disposers, :label)
|
|
14
|
+
|
|
15
|
+
attr_reader :uid, :ctx, :runtime, :inject, :store, :config, :error, :provided, :parent_fiber, :parent_ctx,
|
|
16
|
+
:state
|
|
17
|
+
|
|
18
|
+
def initialize(parent_ctx, config: nil, inject: {}, runtime: nil)
|
|
19
|
+
@config = config
|
|
20
|
+
@inject = inject
|
|
21
|
+
@runtime = runtime
|
|
22
|
+
@disposables = []
|
|
23
|
+
@provided = {} # name => isolate key (for boundary-crossing notify on state change)
|
|
24
|
+
@internal_store = {} # currently satisfied dependencies (name => Impl), continuously updated
|
|
25
|
+
@store = nil # snapshot taken while loading; nil means unloaded
|
|
26
|
+
@current_epoch = INACTIVE
|
|
27
|
+
@target_epoch = INACTIVE
|
|
28
|
+
@inertia = nil # in-flight load/unload transition task
|
|
29
|
+
@error = nil
|
|
30
|
+
@state = :pending
|
|
31
|
+
|
|
32
|
+
if runtime.nil? # root fiber
|
|
33
|
+
@uid = 0
|
|
34
|
+
@ctx = parent_ctx
|
|
35
|
+
@parent_fiber = nil
|
|
36
|
+
@current_epoch = @target_epoch = ''
|
|
37
|
+
@store = {}
|
|
38
|
+
@state = :active
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@uid = parent_ctx.root.registry.next_uid
|
|
43
|
+
@parent_fiber = parent_ctx.fiber
|
|
44
|
+
@parent_ctx = parent_ctx
|
|
45
|
+
@ctx = parent_ctx.extend(fiber: self)
|
|
46
|
+
# inject with a Hash config doubles as an intercept entry on the plugin's ctx
|
|
47
|
+
# (mirrors upstream fiber.ts:139)
|
|
48
|
+
inject.each { |dep, cfg| @ctx = @ctx.intercept(dep, cfg) if cfg.is_a?(Hash) }
|
|
49
|
+
runtime.fibers << self
|
|
50
|
+
@ctx.events.emit('internal/plugin', self)
|
|
51
|
+
inject.each_key { |name| check_impl(name) }
|
|
52
|
+
# a plugin is itself a revertible effect on the parent fiber (mirrors upstream fiber.ts:170)
|
|
53
|
+
@entry = parent_ctx.fiber.effect('ctx.plugin()') do
|
|
54
|
+
refresh
|
|
55
|
+
-> { terminate }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def root? = @runtime.nil?
|
|
60
|
+
def disposed? = @uid.nil?
|
|
61
|
+
|
|
62
|
+
def name
|
|
63
|
+
return 'root' if root?
|
|
64
|
+
|
|
65
|
+
@runtime.name || 'plugin'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Apply a side effect and collect its inverse. The block returns: nil (nothing),
|
|
69
|
+
# a callable (single disposer), or an Array/Enumerator of callables (multi-step
|
|
70
|
+
# disposers, reverted in reverse order — upstream's function* effects).
|
|
71
|
+
# If setup raises midway, already-collected disposers are rolled back in reverse, then re-raised.
|
|
72
|
+
def effect(label = nil, &block)
|
|
73
|
+
assert_active
|
|
74
|
+
disposers = collect_disposers(block, rollback: true)
|
|
75
|
+
entry = Entry.new(disposers, label)
|
|
76
|
+
@disposables << entry
|
|
77
|
+
lambda do
|
|
78
|
+
next unless @disposables.reject! { |e| e.equal?(entry) }
|
|
79
|
+
|
|
80
|
+
entry.disposers.reverse_each(&:call)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def assert_active
|
|
85
|
+
return if @store && %i[loading active].include?(@state)
|
|
86
|
+
|
|
87
|
+
raise InactiveEffectError, 'cannot create effect on inactive context'
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Wait for all in-flight transitions to finish (while, not if: transitions chain).
|
|
91
|
+
def await
|
|
92
|
+
while (task = @inertia)
|
|
93
|
+
task.wait
|
|
94
|
+
end
|
|
95
|
+
raise @error if @error
|
|
96
|
+
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def dispose
|
|
101
|
+
return restart if root?
|
|
102
|
+
|
|
103
|
+
@entry.call
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def restart
|
|
108
|
+
set_epoch(INACTIVE)
|
|
109
|
+
await # wait for the unload first (refreshing right away would set the target back
|
|
110
|
+
# to the original epoch and coalesce the unload intent away)
|
|
111
|
+
refresh
|
|
112
|
+
await
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def update(config)
|
|
116
|
+
@config = config
|
|
117
|
+
restart
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# -- coeffect machinery (dependency-graph updates are fully synchronous;
|
|
121
|
+
# only load/unload execution is async) --
|
|
122
|
+
|
|
123
|
+
def check_impl(name)
|
|
124
|
+
impl = @ctx.root.services[@ctx.isolate_key(name)]
|
|
125
|
+
if impl && impl.fiber.state == :active
|
|
126
|
+
@internal_store[name] = impl
|
|
127
|
+
else
|
|
128
|
+
@internal_store.delete(name)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def refresh
|
|
133
|
+
epoch = ''
|
|
134
|
+
@inject.each_key do |name|
|
|
135
|
+
impl = @internal_store[name]
|
|
136
|
+
if impl.nil?
|
|
137
|
+
epoch = INACTIVE
|
|
138
|
+
break
|
|
139
|
+
end
|
|
140
|
+
epoch = "#{epoch}:#{impl.fiber.uid}"
|
|
141
|
+
end
|
|
142
|
+
set_epoch(epoch)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def collect_disposers(block, rollback: false)
|
|
148
|
+
disposers = []
|
|
149
|
+
result = block.call
|
|
150
|
+
case result
|
|
151
|
+
when nil then nil
|
|
152
|
+
when Array, Enumerator
|
|
153
|
+
# collect incrementally (never force the whole enumeration first): if it raises
|
|
154
|
+
# midway, only disposers yielded so far can be rolled back
|
|
155
|
+
result.each do |disposer|
|
|
156
|
+
validate_disposer(disposer)
|
|
157
|
+
disposers << disposer
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
validate_disposer(result)
|
|
161
|
+
disposers << result
|
|
162
|
+
end
|
|
163
|
+
disposers
|
|
164
|
+
rescue StandardError
|
|
165
|
+
disposers.reverse_each { |d| safe_call(d) } if rollback
|
|
166
|
+
raise
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_disposer(disposer)
|
|
170
|
+
return if disposer.respond_to?(:call)
|
|
171
|
+
|
|
172
|
+
raise TypeError, "invalid effect: disposer must be callable, got #{disposer.inspect}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def safe_call(disposer)
|
|
176
|
+
disposer.call
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
warn "Cordis: error while disposing (#{name}): #{e.class}: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def set_epoch(epoch)
|
|
182
|
+
return if epoch == @target_epoch
|
|
183
|
+
|
|
184
|
+
@target_epoch = epoch
|
|
185
|
+
return if @inertia # transition in flight: just record the intent; re-checked on completion
|
|
186
|
+
|
|
187
|
+
start_transition
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Inertia lock: at most one transition task per fiber at a time; on completion the
|
|
191
|
+
# target epoch is re-checked and, if it changed, the next transition is chained.
|
|
192
|
+
def start_transition
|
|
193
|
+
task = Async::Task.current # raises outside a reactor (plugin/provide require Sync/Async)
|
|
194
|
+
set_state(@current_epoch == INACTIVE ? :loading : :unloading)
|
|
195
|
+
@inertia = task.async do
|
|
196
|
+
Async::Task.current.yield # defer by one tick (upstream's await Promise.resolve())
|
|
197
|
+
loop do
|
|
198
|
+
if @current_epoch == INACTIVE
|
|
199
|
+
break if @target_epoch == INACTIVE
|
|
200
|
+
|
|
201
|
+
do_load
|
|
202
|
+
else
|
|
203
|
+
break if @target_epoch == @current_epoch
|
|
204
|
+
|
|
205
|
+
do_unload
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
@inertia = nil
|
|
209
|
+
set_state(resting_state)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def do_load
|
|
214
|
+
goal = @target_epoch
|
|
215
|
+
@error = nil
|
|
216
|
+
@store = @internal_store.dup # snapshot: a loading plugin sees a frozen view of its deps
|
|
217
|
+
set_state(:loading)
|
|
218
|
+
begin
|
|
219
|
+
result = @runtime.callback.call(@ctx, @config) unless root?
|
|
220
|
+
# ponytail: a plugin return value only counts as a disposer when callable
|
|
221
|
+
# (upstream _execute is stricter and raises TypeError)
|
|
222
|
+
@disposables << Entry.new([result], 'plugin:return') if result.respond_to?(:call)
|
|
223
|
+
rescue StandardError => e
|
|
224
|
+
@error = e
|
|
225
|
+
warn "Cordis: plugin #{name} failed to load: #{e.class}: #{e.message}"
|
|
226
|
+
end
|
|
227
|
+
@current_epoch = goal
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def do_unload
|
|
231
|
+
set_state(:unloading)
|
|
232
|
+
# ponytail: strict LIFO sequential disposal, each rescued individually
|
|
233
|
+
# (upstream starts in LIFO order but finishes concurrently)
|
|
234
|
+
until @disposables.empty?
|
|
235
|
+
entry = @disposables.pop
|
|
236
|
+
entry.disposers.reverse_each { |d| safe_call(d) }
|
|
237
|
+
end
|
|
238
|
+
@store = nil
|
|
239
|
+
@error = nil
|
|
240
|
+
@current_epoch = INACTIVE
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def resting_state
|
|
244
|
+
return :disposed if disposed?
|
|
245
|
+
return :failed if @error
|
|
246
|
+
|
|
247
|
+
@current_epoch == INACTIVE ? :pending : :active
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def set_state(new_state)
|
|
251
|
+
return if new_state == @state
|
|
252
|
+
|
|
253
|
+
old = @state
|
|
254
|
+
@state = new_state
|
|
255
|
+
@ctx.events.emit('internal/status', self, old)
|
|
256
|
+
# crossing the ACTIVE boundary changes the visibility of services this fiber
|
|
257
|
+
# provides -> notify dependents
|
|
258
|
+
crossed = (old == :active) ^ (new_state == :active)
|
|
259
|
+
@ctx.root.notify(@provided.dup) if crossed && !@provided.empty?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Called by the parent's effect disposer (or manually via #dispose)
|
|
263
|
+
def terminate
|
|
264
|
+
set_epoch(INACTIVE)
|
|
265
|
+
begin
|
|
266
|
+
await
|
|
267
|
+
rescue StandardError
|
|
268
|
+
nil # disposing a FAILED fiber never raises (the error was already warned at load time)
|
|
269
|
+
end
|
|
270
|
+
@uid = nil
|
|
271
|
+
set_state(:disposed)
|
|
272
|
+
@ctx.root.registry.remove(self)
|
|
273
|
+
@ctx.events.emit('internal/plugin', self)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cordis
|
|
4
|
+
# Plugin registry: keyed by the resolved callback — one Runtime per callback,
|
|
5
|
+
# applying the same plugin N times = N fibers under one Runtime.
|
|
6
|
+
class Registry
|
|
7
|
+
Runtime = Struct.new(:name, :callback, :fibers, :key)
|
|
8
|
+
|
|
9
|
+
def initialize(ctx)
|
|
10
|
+
@ctx = ctx
|
|
11
|
+
@store = {}
|
|
12
|
+
@counter = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def next_uid = (@counter += 1)
|
|
16
|
+
def size = @store.size
|
|
17
|
+
def runtimes = @store.values
|
|
18
|
+
|
|
19
|
+
# Entry point for ctx.plugin. Plugin shapes: a callable, { apply:, inject:, name: },
|
|
20
|
+
# or a Class (instantiated at load time; see Cordis::Service).
|
|
21
|
+
# Returns the fiber synchronously; the actual apply is deferred one tick
|
|
22
|
+
# (must be called inside Sync/Async).
|
|
23
|
+
def plugin(ctx, plugin, config = nil)
|
|
24
|
+
key, callback, inject, name = resolve(plugin)
|
|
25
|
+
ctx.fiber.assert_active
|
|
26
|
+
runtime = @store[key] ||= Runtime.new(name, callback, [], key)
|
|
27
|
+
Fiber.new(ctx, config: config, inject: inject, runtime: runtime)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def remove(fiber)
|
|
31
|
+
runtime = fiber.runtime
|
|
32
|
+
runtime.fibers.reject! { |f| f.equal?(fiber) }
|
|
33
|
+
@store.delete(runtime.key) if runtime.fibers.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def resolve(plugin)
|
|
39
|
+
case plugin
|
|
40
|
+
when Hash
|
|
41
|
+
apply = plugin[:apply]
|
|
42
|
+
raise ArgumentError, "invalid plugin: apply must be callable, got #{apply.inspect}" \
|
|
43
|
+
unless apply.respond_to?(:call)
|
|
44
|
+
|
|
45
|
+
[apply, apply, normalize_inject(plugin[:inject]), plugin[:name]]
|
|
46
|
+
when Class
|
|
47
|
+
# class plugin (upstream isConstructor): instantiated during load; a Service
|
|
48
|
+
# subclass provides itself in #initialize, then #init runs as the load body
|
|
49
|
+
inject = plugin.respond_to?(:inject) ? plugin.inject : nil
|
|
50
|
+
callback = lambda do |c, config|
|
|
51
|
+
instance = plugin.new(c, config)
|
|
52
|
+
instance.init if instance.respond_to?(:init)
|
|
53
|
+
end
|
|
54
|
+
[plugin, callback, normalize_inject(inject), plugin.name]
|
|
55
|
+
else
|
|
56
|
+
raise ArgumentError, "invalid plugin: #{plugin.inspect}" unless plugin.respond_to?(:call)
|
|
57
|
+
|
|
58
|
+
[plugin, plugin, {}, nil]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize_inject(inject)
|
|
63
|
+
case inject
|
|
64
|
+
when nil then {}
|
|
65
|
+
when Array then inject.to_h { |dep| [dep.to_sym, true] }
|
|
66
|
+
when Hash then inject.transform_keys(&:to_sym)
|
|
67
|
+
else raise ArgumentError, "invalid inject: #{inject.inspect}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cordis
|
|
4
|
+
# Class-style plugin that provides itself as a service (upstream Service base class).
|
|
5
|
+
# Declare the name with `provide :foo` and dependencies with `inject`; the instance
|
|
6
|
+
# becomes the service value, and #init (if defined) runs as the plugin load body.
|
|
7
|
+
#
|
|
8
|
+
# class AuditLog < Cordis::Service
|
|
9
|
+
# provide :audit
|
|
10
|
+
# inject :db
|
|
11
|
+
# def init = ctx.on('request') { ... }
|
|
12
|
+
# end
|
|
13
|
+
# ctx.plugin(AuditLog)
|
|
14
|
+
class Service
|
|
15
|
+
class << self
|
|
16
|
+
# DSL setter and registry-facing getter in one (upstream `static provide`).
|
|
17
|
+
def provide(name = nil)
|
|
18
|
+
@service_name = name.to_sym if name
|
|
19
|
+
@service_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# `inject :foo, :bar` or `inject foo: { config }` (upstream `static inject`);
|
|
23
|
+
# called with no arguments it returns the accumulated map for the registry.
|
|
24
|
+
def inject(*names, **configs)
|
|
25
|
+
@inject ||= {}
|
|
26
|
+
names.each { |dep| @inject[dep.to_sym] = true }
|
|
27
|
+
configs.each { |dep, cfg| @inject[dep.to_sym] = cfg }
|
|
28
|
+
@inject
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :ctx, :name, :config
|
|
33
|
+
|
|
34
|
+
def initialize(ctx, config = nil)
|
|
35
|
+
name = self.class.provide
|
|
36
|
+
raise ArgumentError, "#{self.class} must declare `provide :name`" unless name
|
|
37
|
+
|
|
38
|
+
@ctx = ctx
|
|
39
|
+
@config = config
|
|
40
|
+
@name = name
|
|
41
|
+
ctx.provide(name, self)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Load-time hook: runs while the fiber is still loading, so blocking here keeps
|
|
45
|
+
# the service invisible to dependents (upstream's "pending inject" via Service.init).
|
|
46
|
+
# A callable return value is collected as a disposer.
|
|
47
|
+
def init; end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/cordis.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cordis
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when creating an effect / plugin / listener on a non-active context
|
|
7
|
+
class InactiveEffectError < Error; end
|
|
8
|
+
|
|
9
|
+
# Service access violations (without inject / inactive context / duplicate provide / bad set)
|
|
10
|
+
class ServiceError < Error; end
|
|
11
|
+
|
|
12
|
+
# Aggregated errors from parallel (Ruby has no built-in AggregateError)
|
|
13
|
+
class AggregateError < Error
|
|
14
|
+
attr_reader :errors
|
|
15
|
+
|
|
16
|
+
def initialize(errors)
|
|
17
|
+
@errors = errors
|
|
18
|
+
super("#{errors.size} error(s): #{errors.map(&:message).join('; ')}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
require_relative 'cordis/version'
|
|
24
|
+
require_relative 'cordis/fiber'
|
|
25
|
+
require_relative 'cordis/registry'
|
|
26
|
+
require_relative 'cordis/events'
|
|
27
|
+
require_relative 'cordis/context'
|
|
28
|
+
require_relative 'cordis/service'
|
metadata
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: cordis
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ryudoawaru
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: async
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.35'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.35'
|
|
26
|
+
description: Revertible effects with LIFO disposal, a plugin/fiber lifecycle state
|
|
27
|
+
machine, reactive coeffects (service provide/inject), events, and service isolation
|
|
28
|
+
— the core mechanisms of Cordis (cordiverse/cordis), reimplemented on the async
|
|
29
|
+
gem. Based on the paper "A Programming Paradigm for Spatiotemporal Composability".
|
|
30
|
+
email:
|
|
31
|
+
- ryudoawaru@gmail.com
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- LICENSE
|
|
37
|
+
- README.md
|
|
38
|
+
- lib/cordis.rb
|
|
39
|
+
- lib/cordis/context.rb
|
|
40
|
+
- lib/cordis/events.rb
|
|
41
|
+
- lib/cordis/fiber.rb
|
|
42
|
+
- lib/cordis/registry.rb
|
|
43
|
+
- lib/cordis/service.rb
|
|
44
|
+
- lib/cordis/version.rb
|
|
45
|
+
homepage: https://github.com/5xRuby/cordis-rb
|
|
46
|
+
licenses:
|
|
47
|
+
- Apache-2.0
|
|
48
|
+
metadata:
|
|
49
|
+
source_code_uri: https://github.com/5xRuby/cordis-rb
|
|
50
|
+
changelog_uri: https://github.com/5xRuby/cordis-rb/releases
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.2'
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 4.0.16
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: A Ruby port of the Cordis plugin/lifecycle meta-framework
|
|
69
|
+
test_files: []
|