@reventlessdev/reventless-core 3.0.0-alpha.214 → 3.0.0-alpha.215
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.
- package/CHANGELOG.md +7 -0
- package/package.json +4 -4
- package/src/adapter/RuntimeExtension/RuntimeExtension.res +194 -0
- package/src/adapter/RuntimeExtension/RuntimeExtension.res.mjs +60 -0
- package/tests/adapter/RuntimeExtensionHookReachTest.res +84 -0
- package/tests/adapter/RuntimeExtensionHookReachTest.res.mjs +95 -0
- package/tests/adapter/RuntimeExtensionTest.res +127 -0
- package/tests/adapter/RuntimeExtensionTest.res.mjs +111 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.215 (2026-08-08)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **core,aws,local:** add the RuntimeExtension cold-start seam ([143e30e](https://github.com/ReventlessDev/reventless-core/commit/143e30eeded6232ce7f0fcc18328939b2917bb31))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# 3.0.0-alpha.214 (2026-08-07)
|
|
7
14
|
|
|
8
15
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.215",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,16 +28,16 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-alpha.4",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
31
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
32
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
33
33
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.2",
|
|
34
34
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.2",
|
|
35
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
36
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.2",
|
|
37
|
-
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
38
|
-
"@reventlessdev/rescript-node": "2.0.0-alpha.2",
|
|
39
38
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.127",
|
|
40
39
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
40
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
41
41
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.101"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Runtime hook: every provisioned runtime announces its cold start, so an extension
|
|
3
|
+
can register the runtime callback hooks the framework already ships — before the
|
|
4
|
+
runtime handles its first request.
|
|
5
|
+
|
|
6
|
+
No-op by default. Zero behavioral change unless an extension registers one via
|
|
7
|
+
`use` before the platform/plugin build: nothing is bundled, no env var is set, and
|
|
8
|
+
no code runs.
|
|
9
|
+
|
|
10
|
+
`Monitoring` and `EventLogProvisioning` are the deploy-time half of this shape —
|
|
11
|
+
they let an extension attach behaviour to *what gets provisioned*. This is the
|
|
12
|
+
runtime half: *what runs inside it*. Without it,
|
|
13
|
+
`CommandGenerator_Callback.registerCommandInterceptor`,
|
|
14
|
+
`QueryDb_Callback.registerQueryInterceptor` and the two
|
|
15
|
+
`EventPublish_Callback` publish hooks are unreachable in a deployed runtime —
|
|
16
|
+
each is a module-level `ref` consulted on the hot path, so it is useless unless
|
|
17
|
+
something calls the registrar in the same process first, and the framework-owned
|
|
18
|
+
entry shells import only framework and domain modules. Per-command authorisation,
|
|
19
|
+
request tracing, rate limiting and request accounting are all built from those
|
|
20
|
+
hooks, and each one is otherwise a change to the framework itself.
|
|
21
|
+
|
|
22
|
+
What an extension does at cold start is deliberately NOT a framework concern.
|
|
23
|
+
This only exposes the choke point through which the framework says "a runtime of
|
|
24
|
+
this kind just started, here is who it is" and leaves the reaction to the
|
|
25
|
+
listener.
|
|
26
|
+
|
|
27
|
+
See `docs/plans/done/runtime-extension-seam.md`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
What every registered extension is handed at cold start. Kept to the runtime's
|
|
32
|
+
IDENTITY on purpose: an extension registering a command interceptor needs to know
|
|
33
|
+
which runtime it is inside, not that runtime's storage ops or specs. Widening this
|
|
34
|
+
to internals would make it a second, informal handler API — and the four hooks it
|
|
35
|
+
exists to reach already receive their own per-request payloads
|
|
36
|
+
(`commandInterceptor` gets `~componentName` / `~tag` / `~args` on every call).
|
|
37
|
+
|
|
38
|
+
`~runtimeKind` is the modelling kind of the component the runtime runs — the same
|
|
39
|
+
`ComponentType.t` every AWS runtime builder already passes to
|
|
40
|
+
`RuntimeEnvironment_Lambda.makeFromCodeAsset`, so there is no third taxonomy to
|
|
41
|
+
keep in step with `Monitoring.unitKind` and `ResourceAttribution.Role`.
|
|
42
|
+
|
|
43
|
+
`~component` is the runtime's STATIC logical name — the shared Lambda's name where
|
|
44
|
+
several components of one kind share a runtime (`AllAggregatesCmdHandler`), the
|
|
45
|
+
component's own name where it does not. It identifies the runtime, not a single
|
|
46
|
+
model element; use the per-request `~componentName` on the interception hooks to
|
|
47
|
+
tell hosted components apart.
|
|
48
|
+
|
|
49
|
+
`~plugin` / `~platform` are the owning plugin and platform, taken at provisioning
|
|
50
|
+
time from the ambient `ResourceAttribution` context (the same source `AWS_Tags`,
|
|
51
|
+
`Monitoring.notify` and `EventLogProvisioning.notify` use) and carried into the
|
|
52
|
+
runtime as configuration — the runtime has no ambient deploy context of its own.
|
|
53
|
+
Both are `None` for a runtime provisioned outside any plugin construct.
|
|
54
|
+
*/
|
|
55
|
+
type coldStartHook = (
|
|
56
|
+
~runtimeKind: ComponentType.t,
|
|
57
|
+
~component: string,
|
|
58
|
+
~plugin: option<string>,
|
|
59
|
+
~platform: option<string>,
|
|
60
|
+
) => unit
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
A runtime extension, registered by an extension (a deploy program) before the
|
|
64
|
+
platform builds. `onColdStart` is called once per runtime process, before that
|
|
65
|
+
runtime handles its first request.
|
|
66
|
+
|
|
67
|
+
`moduleUrl` is the extension module's own `import.meta.url`. It is what makes the
|
|
68
|
+
seam reachable in a *deployed* runtime: registering a first-class module only
|
|
69
|
+
populates the deploy program's process, and the Lambda is a different one, so the
|
|
70
|
+
framework bundles the module's package into the code archive and names the module
|
|
71
|
+
in the runtime's config for the entry shell to import. Spec and behavior modules
|
|
72
|
+
are carried into runtimes the same way. The convention matches
|
|
73
|
+
`@@reventless.spec`'s injected `let moduleUrl`, so a ReScript extension can lift
|
|
74
|
+
it from there.
|
|
75
|
+
|
|
76
|
+
**Synchronous.** An async hook would have to be awaited before the first request
|
|
77
|
+
and would put extension latency on every cold path. An extension needing I/O can
|
|
78
|
+
start it here and not block on it.
|
|
79
|
+
|
|
80
|
+
**Fired once, in registration order.** Unlike `EventLogProvisioning` there is no
|
|
81
|
+
scarce resource here (no stream-reader budget), so several extensions compose —
|
|
82
|
+
tracing and accounting are independent concerns. They run in the order they were
|
|
83
|
+
registered; an extension that depends on another having run first is relying on
|
|
84
|
+
its host's statement order, which is the only ordering the framework can promise.
|
|
85
|
+
|
|
86
|
+
**A throwing extension does not take the runtime down.** Each hook is isolated:
|
|
87
|
+
the failure is logged at ERROR and the remaining extensions still run. Failing the
|
|
88
|
+
runtime instead would turn a broken extension into an outage, which is a worse
|
|
89
|
+
trade than a loud, skipped hook.
|
|
90
|
+
*/
|
|
91
|
+
module type Extension = {
|
|
92
|
+
let moduleUrl: string
|
|
93
|
+
let onColdStart: coldStartHook
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let extensions: ref<array<module(Extension)>> = ref([])
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
Register a runtime extension. Must run before the platform/plugin build in the
|
|
100
|
+
deploy program (plain statement order — the registry is read when each runtime's
|
|
101
|
+
code archive and config are built). Additive: call once per extension.
|
|
102
|
+
|
|
103
|
+
There is no `Noop` default to register. The neutral state is the empty registry,
|
|
104
|
+
which is also the state that bundles nothing and sets no env var; a registered
|
|
105
|
+
do-nothing extension would still cost a package in every runtime's archive.
|
|
106
|
+
*/
|
|
107
|
+
let use = (e: module(Extension)) => extensions := extensions.contents->Array.concat([e])
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
Drop every registration. Test-support only — deploy programs never call this.
|
|
111
|
+
*/
|
|
112
|
+
let reset = () => extensions := []
|
|
113
|
+
|
|
114
|
+
/** True when no extension is registered — the default. Deploy-time backends use
|
|
115
|
+
this to skip bundling and config emission entirely, so a deployment that
|
|
116
|
+
registers nothing produces a byte-identical archive. */
|
|
117
|
+
let isEmpty = () => extensions.contents->Array.length == 0
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
The `import.meta.url` of every registered extension, in registration order. Read
|
|
121
|
+
by the provider's code-archive builder to bundle each extension's package and to
|
|
122
|
+
name the modules a deployed runtime imports at cold start.
|
|
123
|
+
*/
|
|
124
|
+
let moduleUrls = (): array<string> =>
|
|
125
|
+
extensions.contents->Array.map(e => {
|
|
126
|
+
module E = unpack(e)
|
|
127
|
+
E.moduleUrl
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
let log = Logger.fromEnv()
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
Run one hook, isolated. A throwing extension is logged at ERROR and skipped; the
|
|
134
|
+
caller keeps going. See the failure-policy note on `Extension`.
|
|
135
|
+
*/
|
|
136
|
+
let runHook = (
|
|
137
|
+
hook: coldStartHook,
|
|
138
|
+
~index: int,
|
|
139
|
+
~runtimeKind: ComponentType.t,
|
|
140
|
+
~component: string,
|
|
141
|
+
~plugin: option<string>,
|
|
142
|
+
~platform: option<string>,
|
|
143
|
+
) =>
|
|
144
|
+
try hook(~runtimeKind, ~component, ~plugin, ~platform) catch {
|
|
145
|
+
| exn =>
|
|
146
|
+
let message =
|
|
147
|
+
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown error")
|
|
148
|
+
log.error(
|
|
149
|
+
~comp="RuntimeExtension",
|
|
150
|
+
`extension ${index->Int.toString} threw at cold start of ${runtimeKind->ComponentType.toString}(${component}); skipped: ${message}`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
Fire the given cold-start hooks, in order, each isolated from the others. Used by
|
|
156
|
+
runtimes that resolve their extensions themselves — a deployed entry point loads
|
|
157
|
+
its extension modules dynamically, so it holds hooks rather than first-class
|
|
158
|
+
modules.
|
|
159
|
+
*/
|
|
160
|
+
let notifyColdStartHooks = (
|
|
161
|
+
~hooks: array<coldStartHook>,
|
|
162
|
+
~runtimeKind: ComponentType.t,
|
|
163
|
+
~component: string,
|
|
164
|
+
~plugin: option<string>,
|
|
165
|
+
~platform: option<string>,
|
|
166
|
+
) =>
|
|
167
|
+
hooks->Array.forEachWithIndex((hook, index) =>
|
|
168
|
+
hook->runHook(~index, ~runtimeKind, ~component, ~plugin, ~platform)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
Announce a runtime's cold start to every extension registered in THIS process.
|
|
173
|
+
Called by in-process runtimes (the local platform), where the deploy program and
|
|
174
|
+
the runtime share a process so the registry is already populated. A deployed
|
|
175
|
+
runtime cannot use this — its registry is empty until it imports the extension
|
|
176
|
+
modules named in its config — and calls `notifyColdStartHooks` instead. No-op
|
|
177
|
+
until an extension registers.
|
|
178
|
+
*/
|
|
179
|
+
let notifyColdStart = (
|
|
180
|
+
~runtimeKind: ComponentType.t,
|
|
181
|
+
~component: string,
|
|
182
|
+
~plugin: option<string>,
|
|
183
|
+
~platform: option<string>,
|
|
184
|
+
) =>
|
|
185
|
+
notifyColdStartHooks(
|
|
186
|
+
~hooks=extensions.contents->Array.map(e => {
|
|
187
|
+
module E = unpack(e)
|
|
188
|
+
E.onColdStart
|
|
189
|
+
}),
|
|
190
|
+
~runtimeKind,
|
|
191
|
+
~component,
|
|
192
|
+
~plugin,
|
|
193
|
+
~platform,
|
|
194
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
4
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
6
|
+
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
7
|
+
import * as ComponentType$ReventlessCore from "../../ComponentType.res.mjs";
|
|
8
|
+
|
|
9
|
+
let extensions = {
|
|
10
|
+
contents: []
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function use(e) {
|
|
14
|
+
extensions.contents = extensions.contents.concat([e]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function reset() {
|
|
18
|
+
extensions.contents = [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isEmpty() {
|
|
22
|
+
return extensions.contents.length === 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function moduleUrls() {
|
|
26
|
+
return extensions.contents.map(e => e.moduleUrl);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
30
|
+
|
|
31
|
+
function runHook(hook, index, runtimeKind, component, plugin, platform) {
|
|
32
|
+
try {
|
|
33
|
+
return hook(runtimeKind, component, plugin, platform);
|
|
34
|
+
} catch (raw_exn) {
|
|
35
|
+
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
36
|
+
let message = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown error");
|
|
37
|
+
return log.error("RuntimeExtension", undefined, `extension ` + index.toString() + ` threw at cold start of ` + ComponentType$ReventlessCore.toString(runtimeKind) + `(` + component + `); skipped: ` + message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function notifyColdStartHooks(hooks, runtimeKind, component, plugin, platform) {
|
|
42
|
+
hooks.forEach((hook, index) => runHook(hook, index, runtimeKind, component, plugin, platform));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function notifyColdStart(runtimeKind, component, plugin, platform) {
|
|
46
|
+
notifyColdStartHooks(extensions.contents.map(e => e.onColdStart), runtimeKind, component, plugin, platform);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
extensions,
|
|
51
|
+
use,
|
|
52
|
+
reset,
|
|
53
|
+
isEmpty,
|
|
54
|
+
moduleUrls,
|
|
55
|
+
log,
|
|
56
|
+
runHook,
|
|
57
|
+
notifyColdStartHooks,
|
|
58
|
+
notifyColdStart,
|
|
59
|
+
}
|
|
60
|
+
/* log Not a pure module */
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
S.enableJson()
|
|
4
|
+
|
|
5
|
+
// The point of the seam, end to end at the hook level: an extension that only
|
|
6
|
+
// ever gets `onColdStart` can reach the framework's runtime callback hooks from
|
|
7
|
+
// there, and they take effect on the requests that follow.
|
|
8
|
+
//
|
|
9
|
+
// `CommandGenerator_Callback.registerCommandInterceptor` stands in for all four
|
|
10
|
+
// registrars — they share the shape (a module-level ref consulted on the hot
|
|
11
|
+
// path), so what makes one reachable makes the others reachable too. What is
|
|
12
|
+
// being proved is the ordering: registration happens inside the cold-start hook,
|
|
13
|
+
// the command runs after it, and the interceptor is consulted.
|
|
14
|
+
|
|
15
|
+
@schema
|
|
16
|
+
type command = Add({@partitionTag productId: string, name: string})
|
|
17
|
+
|
|
18
|
+
let published: ref<array<Message.commandJson>> = ref([])
|
|
19
|
+
let publishJsons: CommandGenerator.publishJsons = async cmds =>
|
|
20
|
+
published := published.contents->Array.concat(cmds)
|
|
21
|
+
|
|
22
|
+
let generateCommand = CommandGenerator_Callback.makeGenerateCommand(
|
|
23
|
+
~publishJsons,
|
|
24
|
+
~serviceName="Product",
|
|
25
|
+
~commandSchema=commandSchema->S.castToUnknown,
|
|
26
|
+
~componentKind=CommandGenerator_Callback.StateChangeSlice,
|
|
27
|
+
~stripIdFromParams=false,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
let addChair = (): CommandGenerator.payload =>
|
|
31
|
+
Obj.magic({
|
|
32
|
+
"command": "Add",
|
|
33
|
+
"arguments": {"productId": "p1", "name": "Chair"},
|
|
34
|
+
"meta": {"ip": ["127.0.0.1"], "user": "test-user", "info": ""},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
let runAdd = async () => await generateCommand(addChair())->Effect.runPromise
|
|
38
|
+
|
|
39
|
+
describe("RuntimeExtension reaches the runtime callback hooks", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
RuntimeExtension.reset()
|
|
42
|
+
CommandGenerator_Callback.clearCommandInterceptor()
|
|
43
|
+
published := []
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
testPromise("an interceptor registered at cold start denies a later command", async () => {
|
|
47
|
+
let seenAtColdStart: array<string> = []
|
|
48
|
+
module Gatekeeper: RuntimeExtension.Extension = {
|
|
49
|
+
let moduleUrl = "file:///pkg/gatekeeper.res.mjs"
|
|
50
|
+
let onColdStart = (~runtimeKind as _, ~component, ~plugin as _, ~platform as _) => {
|
|
51
|
+
seenAtColdStart->Array.push(component)
|
|
52
|
+
CommandGenerator_Callback.registerCommandInterceptor(async (
|
|
53
|
+
~identity as _,
|
|
54
|
+
~componentName,
|
|
55
|
+
~componentKind as _,
|
|
56
|
+
~tag,
|
|
57
|
+
~args as _,
|
|
58
|
+
) => CommandGenerator_Callback.Deny(`${componentName}.${tag} refused`))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
RuntimeExtension.use(module(Gatekeeper: RuntimeExtension.Extension))
|
|
62
|
+
|
|
63
|
+
RuntimeExtension.notifyColdStart(
|
|
64
|
+
~runtimeKind=ComponentType.StateChangeSlice,
|
|
65
|
+
~component="CatalogStateChanges",
|
|
66
|
+
~plugin=Some("Catalog"),
|
|
67
|
+
~platform=Some("Shop"),
|
|
68
|
+
)
|
|
69
|
+
expect(seenAtColdStart)->toEqual(["CatalogStateChanges"])
|
|
70
|
+
|
|
71
|
+
let denied = switch await runAdd() {
|
|
72
|
+
| _ => None
|
|
73
|
+
| exception exn => exn->JsExn.fromException->Option.flatMap(JsExn.message)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expect(denied)->toEqual(Some("Product.Add refused"))
|
|
77
|
+
expect(published.contents->Array.length)->toBe(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
testPromise("with nothing registered the same command publishes untouched", async () => {
|
|
81
|
+
let _ = await runAdd()
|
|
82
|
+
expect(published.contents->Array.length)->toBe(1)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
8
|
+
import * as RuntimeExtension$ReventlessCore from "../../src/adapter/RuntimeExtension/RuntimeExtension.res.mjs";
|
|
9
|
+
import * as CommandGenerator_Callback$ReventlessCore from "../../src/components/CommandGenerator/CommandGenerator_Callback.res.mjs";
|
|
10
|
+
|
|
11
|
+
S.enableJson();
|
|
12
|
+
|
|
13
|
+
let commandSchema = S.schema(s => ({
|
|
14
|
+
TAG: "Add",
|
|
15
|
+
productId: s.m(S.string),
|
|
16
|
+
name: s.m(S.string)
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
let published = {
|
|
20
|
+
contents: []
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function publishJsons(cmds) {
|
|
24
|
+
published.contents = published.contents.concat(cmds);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let generateCommand = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, undefined, "Product", commandSchema, "StateChangeSlice", false);
|
|
28
|
+
|
|
29
|
+
function addChair() {
|
|
30
|
+
return {
|
|
31
|
+
command: "Add",
|
|
32
|
+
arguments: {
|
|
33
|
+
productId: "p1",
|
|
34
|
+
name: "Chair"
|
|
35
|
+
},
|
|
36
|
+
meta: {
|
|
37
|
+
ip: ["127.0.0.1"],
|
|
38
|
+
user: "test-user",
|
|
39
|
+
info: ""
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function runAdd() {
|
|
45
|
+
return await Effect.runPromise(generateCommand(addChair()));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
globalThis.describe("RuntimeExtension reaches the runtime callback hooks", () => {
|
|
49
|
+
globalThis.beforeEach(() => {
|
|
50
|
+
RuntimeExtension$ReventlessCore.reset();
|
|
51
|
+
CommandGenerator_Callback$ReventlessCore.clearCommandInterceptor();
|
|
52
|
+
published.contents = [];
|
|
53
|
+
});
|
|
54
|
+
globalThis.test("an interceptor registered at cold start denies a later command", async () => {
|
|
55
|
+
let seenAtColdStart = [];
|
|
56
|
+
let onColdStart = (param, component, param$1, param$2) => {
|
|
57
|
+
seenAtColdStart.push(component);
|
|
58
|
+
CommandGenerator_Callback$ReventlessCore.registerCommandInterceptor(async (param, componentName, param$1, tag, param$2) => ({
|
|
59
|
+
TAG: "Deny",
|
|
60
|
+
_0: componentName + `.` + tag + ` refused`
|
|
61
|
+
}));
|
|
62
|
+
};
|
|
63
|
+
let Gatekeeper = {
|
|
64
|
+
moduleUrl: "file:///pkg/gatekeeper.res.mjs",
|
|
65
|
+
onColdStart: onColdStart
|
|
66
|
+
};
|
|
67
|
+
RuntimeExtension$ReventlessCore.use(Gatekeeper);
|
|
68
|
+
RuntimeExtension$ReventlessCore.notifyColdStart("StateChangeSlice", "CatalogStateChanges", "Catalog", "Shop");
|
|
69
|
+
globalThis.expect(seenAtColdStart).toEqual(["CatalogStateChanges"]);
|
|
70
|
+
let denied;
|
|
71
|
+
try {
|
|
72
|
+
await runAdd();
|
|
73
|
+
denied = undefined;
|
|
74
|
+
} catch (raw_exn) {
|
|
75
|
+
let exn = Primitive_exceptions.internalToException(raw_exn);
|
|
76
|
+
denied = Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message);
|
|
77
|
+
}
|
|
78
|
+
globalThis.expect(denied).toEqual("Product.Add refused");
|
|
79
|
+
globalThis.expect(published.contents.length).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
globalThis.test("with nothing registered the same command publishes untouched", async () => {
|
|
82
|
+
await runAdd();
|
|
83
|
+
globalThis.expect(published.contents.length).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
commandSchema,
|
|
89
|
+
published,
|
|
90
|
+
publishJsons,
|
|
91
|
+
generateCommand,
|
|
92
|
+
addChair,
|
|
93
|
+
runAdd,
|
|
94
|
+
}
|
|
95
|
+
/* Not a pure module */
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
type call = {
|
|
4
|
+
runtimeKind: ComponentType.t,
|
|
5
|
+
component: string,
|
|
6
|
+
plugin: option<string>,
|
|
7
|
+
platform: option<string>,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let recorderNamed = (label: string, into: array<(string, call)>): module(
|
|
11
|
+
RuntimeExtension.Extension
|
|
12
|
+
) => {
|
|
13
|
+
module R = {
|
|
14
|
+
let moduleUrl = `file:///pkg/${label}.res.mjs`
|
|
15
|
+
let onColdStart = (~runtimeKind, ~component, ~plugin, ~platform) =>
|
|
16
|
+
into->Array.push((label, {runtimeKind, component, plugin, platform}))
|
|
17
|
+
}
|
|
18
|
+
module(R: RuntimeExtension.Extension)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let fireAggregate = () =>
|
|
22
|
+
RuntimeExtension.notifyColdStart(
|
|
23
|
+
~runtimeKind=ComponentType.Aggregate,
|
|
24
|
+
~component="AllAggregatesCmdHandler",
|
|
25
|
+
~plugin=Some("Catalog"),
|
|
26
|
+
~platform=Some("Shop"),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
describe("RuntimeExtension", () => {
|
|
30
|
+
beforeEach(() => RuntimeExtension.reset())
|
|
31
|
+
|
|
32
|
+
testSync("empty registry is the default and notifyColdStart is silent", () => {
|
|
33
|
+
expect(RuntimeExtension.isEmpty())->toBe(true)
|
|
34
|
+
expect(RuntimeExtension.moduleUrls())->toEqual([])
|
|
35
|
+
fireAggregate()
|
|
36
|
+
expect(true)->toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
testSync("a registered extension receives the runtime's full identity", () => {
|
|
40
|
+
let calls: array<(string, call)> = []
|
|
41
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
42
|
+
|
|
43
|
+
fireAggregate()
|
|
44
|
+
|
|
45
|
+
expect(calls)->toEqual([
|
|
46
|
+
(
|
|
47
|
+
"tracing",
|
|
48
|
+
{
|
|
49
|
+
runtimeKind: ComponentType.Aggregate,
|
|
50
|
+
component: "AllAggregatesCmdHandler",
|
|
51
|
+
plugin: Some("Catalog"),
|
|
52
|
+
platform: Some("Shop"),
|
|
53
|
+
},
|
|
54
|
+
),
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
testSync("several extensions compose, in registration order", () => {
|
|
59
|
+
let calls: array<(string, call)> = []
|
|
60
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
61
|
+
RuntimeExtension.use(recorderNamed("accounting", calls))
|
|
62
|
+
|
|
63
|
+
fireAggregate()
|
|
64
|
+
|
|
65
|
+
expect(calls->Array.map(((label, _)) => label))->toEqual(["tracing", "accounting"])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
testSync("moduleUrls reports every registration, in order", () => {
|
|
69
|
+
let calls: array<(string, call)> = []
|
|
70
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
71
|
+
RuntimeExtension.use(recorderNamed("accounting", calls))
|
|
72
|
+
|
|
73
|
+
expect(RuntimeExtension.isEmpty())->toBe(false)
|
|
74
|
+
expect(RuntimeExtension.moduleUrls())->toEqual([
|
|
75
|
+
"file:///pkg/tracing.res.mjs",
|
|
76
|
+
"file:///pkg/accounting.res.mjs",
|
|
77
|
+
])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
testSync("a throwing extension is skipped; the runtime and its siblings survive", () => {
|
|
81
|
+
let calls: array<(string, call)> = []
|
|
82
|
+
module Broken: RuntimeExtension.Extension = {
|
|
83
|
+
let moduleUrl = "file:///pkg/broken.res.mjs"
|
|
84
|
+
let onColdStart = (~runtimeKind as _, ~component as _, ~plugin as _, ~platform as _) =>
|
|
85
|
+
JsError.throwWithMessage("extension blew up")
|
|
86
|
+
}
|
|
87
|
+
RuntimeExtension.use(module(Broken: RuntimeExtension.Extension))
|
|
88
|
+
RuntimeExtension.use(recorderNamed("accounting", calls))
|
|
89
|
+
|
|
90
|
+
// Fails the test if the seam propagates: the acceptance criterion is that a
|
|
91
|
+
// broken extension does not take the runtime down.
|
|
92
|
+
fireAggregate()
|
|
93
|
+
|
|
94
|
+
expect(calls->Array.map(((label, _)) => label))->toEqual(["accounting"])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
testSync("notifyColdStartHooks fires loose hooks — the deployed runtime's path", () => {
|
|
98
|
+
// A deployed entry point holds hooks lifted off dynamically imported
|
|
99
|
+
// modules, not first-class modules, so it never populates this process's
|
|
100
|
+
// registry. Same isolation and ordering apply.
|
|
101
|
+
let seen: array<string> = []
|
|
102
|
+
let hook = (label): RuntimeExtension.coldStartHook =>
|
|
103
|
+
(~runtimeKind as _, ~component, ~plugin as _, ~platform as _) =>
|
|
104
|
+
seen->Array.push(`${label}:${component}`)
|
|
105
|
+
|
|
106
|
+
RuntimeExtension.notifyColdStartHooks(
|
|
107
|
+
~hooks=[hook("a"), hook("b")],
|
|
108
|
+
~runtimeKind=ComponentType.ReadModel,
|
|
109
|
+
~component="AllReadModels",
|
|
110
|
+
~plugin=None,
|
|
111
|
+
~platform=None,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
expect(seen)->toEqual(["a:AllReadModels", "b:AllReadModels"])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
testSync("reset clears the registry", () => {
|
|
118
|
+
let calls: array<(string, call)> = []
|
|
119
|
+
RuntimeExtension.use(recorderNamed("tracing", calls))
|
|
120
|
+
RuntimeExtension.reset()
|
|
121
|
+
|
|
122
|
+
fireAggregate()
|
|
123
|
+
|
|
124
|
+
expect(RuntimeExtension.isEmpty())->toBe(true)
|
|
125
|
+
expect(calls)->toEqual([])
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
4
|
+
import * as RuntimeExtension$ReventlessCore from "../../src/adapter/RuntimeExtension/RuntimeExtension.res.mjs";
|
|
5
|
+
|
|
6
|
+
function recorderNamed(label, into) {
|
|
7
|
+
let moduleUrl = `file:///pkg/` + label + `.res.mjs`;
|
|
8
|
+
let onColdStart = (runtimeKind, component, plugin, platform) => {
|
|
9
|
+
into.push([
|
|
10
|
+
label,
|
|
11
|
+
{
|
|
12
|
+
runtimeKind: runtimeKind,
|
|
13
|
+
component: component,
|
|
14
|
+
plugin: plugin,
|
|
15
|
+
platform: platform
|
|
16
|
+
}
|
|
17
|
+
]);
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
moduleUrl: moduleUrl,
|
|
21
|
+
onColdStart: onColdStart
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fireAggregate() {
|
|
26
|
+
RuntimeExtension$ReventlessCore.notifyColdStart("Aggregate", "AllAggregatesCmdHandler", "Catalog", "Shop");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
globalThis.describe("RuntimeExtension", () => {
|
|
30
|
+
globalThis.beforeEach(() => RuntimeExtension$ReventlessCore.reset());
|
|
31
|
+
globalThis.test("empty registry is the default and notifyColdStart is silent", () => {
|
|
32
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.isEmpty()).toBe(true);
|
|
33
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.moduleUrls()).toEqual([]);
|
|
34
|
+
fireAggregate();
|
|
35
|
+
globalThis.expect(true).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
globalThis.test("a registered extension receives the runtime's full identity", () => {
|
|
38
|
+
let calls = [];
|
|
39
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
40
|
+
fireAggregate();
|
|
41
|
+
globalThis.expect(calls).toEqual([[
|
|
42
|
+
"tracing",
|
|
43
|
+
{
|
|
44
|
+
runtimeKind: "Aggregate",
|
|
45
|
+
component: "AllAggregatesCmdHandler",
|
|
46
|
+
plugin: "Catalog",
|
|
47
|
+
platform: "Shop"
|
|
48
|
+
}
|
|
49
|
+
]]);
|
|
50
|
+
});
|
|
51
|
+
globalThis.test("several extensions compose, in registration order", () => {
|
|
52
|
+
let calls = [];
|
|
53
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
54
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("accounting", calls));
|
|
55
|
+
fireAggregate();
|
|
56
|
+
globalThis.expect(calls.map(param => param[0])).toEqual([
|
|
57
|
+
"tracing",
|
|
58
|
+
"accounting"
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
globalThis.test("moduleUrls reports every registration, in order", () => {
|
|
62
|
+
let calls = [];
|
|
63
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
64
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("accounting", calls));
|
|
65
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.isEmpty()).toBe(false);
|
|
66
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.moduleUrls()).toEqual([
|
|
67
|
+
"file:///pkg/tracing.res.mjs",
|
|
68
|
+
"file:///pkg/accounting.res.mjs"
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
globalThis.test("a throwing extension is skipped; the runtime and its siblings survive", () => {
|
|
72
|
+
let calls = [];
|
|
73
|
+
let onColdStart = (param, param$1, param$2, param$3) => Stdlib_JsError.throwWithMessage("extension blew up");
|
|
74
|
+
let Broken = {
|
|
75
|
+
moduleUrl: "file:///pkg/broken.res.mjs",
|
|
76
|
+
onColdStart: onColdStart
|
|
77
|
+
};
|
|
78
|
+
RuntimeExtension$ReventlessCore.use(Broken);
|
|
79
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("accounting", calls));
|
|
80
|
+
fireAggregate();
|
|
81
|
+
globalThis.expect(calls.map(param => param[0])).toEqual(["accounting"]);
|
|
82
|
+
});
|
|
83
|
+
globalThis.test("notifyColdStartHooks fires loose hooks — the deployed runtime's path", () => {
|
|
84
|
+
let seen = [];
|
|
85
|
+
let hook = label => ((param, component, param$1, param$2) => {
|
|
86
|
+
seen.push(label + `:` + component);
|
|
87
|
+
});
|
|
88
|
+
RuntimeExtension$ReventlessCore.notifyColdStartHooks([
|
|
89
|
+
hook("a"),
|
|
90
|
+
hook("b")
|
|
91
|
+
], "ReadModel", "AllReadModels", undefined, undefined);
|
|
92
|
+
globalThis.expect(seen).toEqual([
|
|
93
|
+
"a:AllReadModels",
|
|
94
|
+
"b:AllReadModels"
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
globalThis.test("reset clears the registry", () => {
|
|
98
|
+
let calls = [];
|
|
99
|
+
RuntimeExtension$ReventlessCore.use(recorderNamed("tracing", calls));
|
|
100
|
+
RuntimeExtension$ReventlessCore.reset();
|
|
101
|
+
fireAggregate();
|
|
102
|
+
globalThis.expect(RuntimeExtension$ReventlessCore.isEmpty()).toBe(true);
|
|
103
|
+
globalThis.expect(calls).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
recorderNamed,
|
|
109
|
+
fireAggregate,
|
|
110
|
+
}
|
|
111
|
+
/* Not a pure module */
|