@reventlessdev/reventless-core 3.0.0-alpha.213 → 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 +14 -0
- package/package.json +3 -3
- package/src/adapter/EventLogProvisioning/EventLogProvisioning.res +165 -0
- package/src/adapter/EventLogProvisioning/EventLogProvisioning.res.mjs +49 -0
- package/src/adapter/RuntimeExtension/RuntimeExtension.res +194 -0
- package/src/adapter/RuntimeExtension/RuntimeExtension.res.mjs +60 -0
- package/src/components/DcbEventLog/DcbEventLog_Builder.res +17 -4
- package/src/components/DcbEventLog/DcbEventLog_Builder.res.mjs +9 -5
- package/src/components/EventLog/EventLog_Builder.res +10 -2
- package/src/components/EventLog/EventLog_Builder.res.mjs +4 -1
- package/tests/adapter/EventLogProvisioningTest.res +207 -0
- package/tests/adapter/EventLogProvisioningTest.res.mjs +150 -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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The registry only forwards `~resources` and `~opts`; it never reads their
|
|
4
|
+
// fields. A structural stand-in keeps @pulumi/pulumi (whose Output.make would
|
|
5
|
+
// build a real resource) out of the Jest run — its ESM entry can't be imported
|
|
6
|
+
// under Jest. Same reason as MonitoringTest's stub.
|
|
7
|
+
let stubResource: ReventlessInfra.Adapter.resource = %raw(`{}`)
|
|
8
|
+
let noOpts: Pulumi.CustomResourceOptions.t = {}
|
|
9
|
+
|
|
10
|
+
let classicOwner: ResourceAttribution.owner = {kind: ComponentType.Aggregate, name: "Product"}
|
|
11
|
+
let dcbOwner: ResourceAttribution.owner = {kind: ComponentType.Plugin, name: "Catalog"}
|
|
12
|
+
|
|
13
|
+
describe("EventLogProvisioning", () => {
|
|
14
|
+
testSync("Noop default: notify is silent and does not throw", () => {
|
|
15
|
+
// No backend registered — the default Noop backend swallows the call.
|
|
16
|
+
EventLogProvisioning.notify(
|
|
17
|
+
~logStyle=Classic,
|
|
18
|
+
~name="ProductEventLog",
|
|
19
|
+
~owner=Some(classicOwner),
|
|
20
|
+
~resources=[],
|
|
21
|
+
~opts=noOpts,
|
|
22
|
+
)
|
|
23
|
+
expect(true)->toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
testSync("use registers a backend that receives every notify, style and name", () => {
|
|
27
|
+
let recorded: array<(EventLogProvisioning.logStyle, string)> = []
|
|
28
|
+
module Recorder: EventLogProvisioning.Backend = {
|
|
29
|
+
let onProvisioned = (
|
|
30
|
+
~logStyle,
|
|
31
|
+
~name,
|
|
32
|
+
~owner as _,
|
|
33
|
+
~plugin as _,
|
|
34
|
+
~platform as _,
|
|
35
|
+
~resources as _,
|
|
36
|
+
~opts as _,
|
|
37
|
+
) => recorded->Array.push((logStyle, name))
|
|
38
|
+
}
|
|
39
|
+
EventLogProvisioning.use(module(Recorder: EventLogProvisioning.Backend))
|
|
40
|
+
|
|
41
|
+
EventLogProvisioning.notify(
|
|
42
|
+
~logStyle=Classic,
|
|
43
|
+
~name="ProductEventLog",
|
|
44
|
+
~owner=Some(classicOwner),
|
|
45
|
+
~resources=[],
|
|
46
|
+
~opts=noOpts,
|
|
47
|
+
)
|
|
48
|
+
EventLogProvisioning.notify(
|
|
49
|
+
~logStyle=Dcb,
|
|
50
|
+
~name="CatalogDcbEventLog",
|
|
51
|
+
~owner=Some(dcbOwner),
|
|
52
|
+
~resources=[],
|
|
53
|
+
~opts=noOpts,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
expect(recorded)->toEqual([
|
|
57
|
+
(EventLogProvisioning.Classic, "ProductEventLog"),
|
|
58
|
+
(EventLogProvisioning.Dcb, "CatalogDcbEventLog"),
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
// Restore the default so registry state doesn't leak to other cases.
|
|
62
|
+
EventLogProvisioning.reset()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
testSync("notify forwards the owner and the adapter's resources unchanged", () => {
|
|
66
|
+
let seen: ref<
|
|
67
|
+
option<(option<ResourceAttribution.owner>, array<ReventlessInfra.Adapter.resource>)>,
|
|
68
|
+
> = ref(None)
|
|
69
|
+
module Capture: EventLogProvisioning.Backend = {
|
|
70
|
+
let onProvisioned = (
|
|
71
|
+
~logStyle as _,
|
|
72
|
+
~name as _,
|
|
73
|
+
~owner,
|
|
74
|
+
~plugin as _,
|
|
75
|
+
~platform as _,
|
|
76
|
+
~resources,
|
|
77
|
+
~opts as _,
|
|
78
|
+
) => seen := Some((owner, resources))
|
|
79
|
+
}
|
|
80
|
+
EventLogProvisioning.use(module(Capture: EventLogProvisioning.Backend))
|
|
81
|
+
|
|
82
|
+
EventLogProvisioning.notify(
|
|
83
|
+
~logStyle=Classic,
|
|
84
|
+
~name="ProductEventLog",
|
|
85
|
+
~owner=Some(classicOwner),
|
|
86
|
+
~resources=[stubResource],
|
|
87
|
+
~opts=noOpts,
|
|
88
|
+
)
|
|
89
|
+
switch seen.contents {
|
|
90
|
+
| Some((owner, resources)) =>
|
|
91
|
+
expect(owner)->toEqual(Some(classicOwner))
|
|
92
|
+
expect(resources)->toHaveLength(1)
|
|
93
|
+
| None => expect("no notify received")->toBe("a notify")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
EventLogProvisioning.reset()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
testSync("a Postgres-style log with no resources still fires — the contract is uniform", () => {
|
|
100
|
+
// Postgres and in-memory adapters return `resources: []`. Backends that only
|
|
101
|
+
// implement a DynamoDB arm ignore those; the seam does not filter for them.
|
|
102
|
+
let styles: array<EventLogProvisioning.logStyle> = []
|
|
103
|
+
module Recorder: EventLogProvisioning.Backend = {
|
|
104
|
+
let onProvisioned = (
|
|
105
|
+
~logStyle,
|
|
106
|
+
~name as _,
|
|
107
|
+
~owner as _,
|
|
108
|
+
~plugin as _,
|
|
109
|
+
~platform as _,
|
|
110
|
+
~resources,
|
|
111
|
+
~opts as _,
|
|
112
|
+
) =>
|
|
113
|
+
if resources->Array.length == 0 {
|
|
114
|
+
styles->Array.push(logStyle)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
EventLogProvisioning.use(module(Recorder: EventLogProvisioning.Backend))
|
|
118
|
+
|
|
119
|
+
EventLogProvisioning.notify(
|
|
120
|
+
~logStyle=Dcb,
|
|
121
|
+
~name="CatalogDcbEventLog",
|
|
122
|
+
~owner=Some(dcbOwner),
|
|
123
|
+
~resources=[],
|
|
124
|
+
~opts=noOpts,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(styles)->toEqual([EventLogProvisioning.Dcb])
|
|
128
|
+
EventLogProvisioning.reset()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
testSync("notify delivers the ambient plugin/platform inside a construct scope, None outside", () => {
|
|
132
|
+
let seen: array<(option<string>, option<string>)> = []
|
|
133
|
+
module OwnerCapture: EventLogProvisioning.Backend = {
|
|
134
|
+
let onProvisioned = (
|
|
135
|
+
~logStyle as _,
|
|
136
|
+
~name as _,
|
|
137
|
+
~owner as _,
|
|
138
|
+
~plugin,
|
|
139
|
+
~platform,
|
|
140
|
+
~resources as _,
|
|
141
|
+
~opts as _,
|
|
142
|
+
) => seen->Array.push((plugin, platform))
|
|
143
|
+
}
|
|
144
|
+
EventLogProvisioning.use(module(OwnerCapture: EventLogProvisioning.Backend))
|
|
145
|
+
|
|
146
|
+
// Outside any plugin construct: both None.
|
|
147
|
+
EventLogProvisioning.notify(
|
|
148
|
+
~logStyle=Classic,
|
|
149
|
+
~name="ProductEventLog",
|
|
150
|
+
~owner=Some(classicOwner),
|
|
151
|
+
~resources=[],
|
|
152
|
+
~opts=noOpts,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// Inside a construct scope (what Plugin_Builder.construct establishes): both delivered.
|
|
156
|
+
let prev = ResourceAttribution.enter(~platform="online-shop", ~plugin="Catalog")
|
|
157
|
+
EventLogProvisioning.notify(
|
|
158
|
+
~logStyle=Dcb,
|
|
159
|
+
~name="CatalogDcbEventLog",
|
|
160
|
+
~owner=Some(dcbOwner),
|
|
161
|
+
~resources=[],
|
|
162
|
+
~opts=noOpts,
|
|
163
|
+
)
|
|
164
|
+
ResourceAttribution.restore(prev)
|
|
165
|
+
|
|
166
|
+
// After restore: back to None.
|
|
167
|
+
EventLogProvisioning.notify(
|
|
168
|
+
~logStyle=Classic,
|
|
169
|
+
~name="OrderEventLog",
|
|
170
|
+
~owner=None,
|
|
171
|
+
~resources=[],
|
|
172
|
+
~opts=noOpts,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
expect(seen)->toEqual([(None, None), (Some("Catalog"), Some("online-shop")), (None, None)])
|
|
176
|
+
|
|
177
|
+
EventLogProvisioning.reset()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
testSync("use is single-slot: a second registration throws rather than silently winning", () => {
|
|
181
|
+
module First: EventLogProvisioning.Backend = {
|
|
182
|
+
let onProvisioned = (
|
|
183
|
+
~logStyle as _,
|
|
184
|
+
~name as _,
|
|
185
|
+
~owner as _,
|
|
186
|
+
~plugin as _,
|
|
187
|
+
~platform as _,
|
|
188
|
+
~resources as _,
|
|
189
|
+
~opts as _,
|
|
190
|
+
) => ()
|
|
191
|
+
}
|
|
192
|
+
EventLogProvisioning.use(module(First: EventLogProvisioning.Backend))
|
|
193
|
+
|
|
194
|
+
let threw = try {
|
|
195
|
+
EventLogProvisioning.use(module(First: EventLogProvisioning.Backend))
|
|
196
|
+
false
|
|
197
|
+
} catch {
|
|
198
|
+
| _ => true
|
|
199
|
+
}
|
|
200
|
+
expect(threw)->toBe(true)
|
|
201
|
+
|
|
202
|
+
// reset clears the slot, so a later registration succeeds again.
|
|
203
|
+
EventLogProvisioning.reset()
|
|
204
|
+
EventLogProvisioning.use(module(First: EventLogProvisioning.Backend))
|
|
205
|
+
EventLogProvisioning.reset()
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as ResourceAttribution$ReventlessCore from "../../src/ResourceAttribution.res.mjs";
|
|
4
|
+
import * as EventLogProvisioning$ReventlessCore from "../../src/adapter/EventLogProvisioning/EventLogProvisioning.res.mjs";
|
|
5
|
+
|
|
6
|
+
let stubResource = {};
|
|
7
|
+
|
|
8
|
+
let noOpts = {};
|
|
9
|
+
|
|
10
|
+
let classicOwner = {
|
|
11
|
+
kind: "Aggregate",
|
|
12
|
+
name: "Product"
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let dcbOwner = {
|
|
16
|
+
kind: "Plugin",
|
|
17
|
+
name: "Catalog"
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
globalThis.describe("EventLogProvisioning", () => {
|
|
21
|
+
globalThis.test("Noop default: notify is silent and does not throw", () => {
|
|
22
|
+
EventLogProvisioning$ReventlessCore.notify("Classic", "ProductEventLog", classicOwner, [], noOpts);
|
|
23
|
+
globalThis.expect(true).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
globalThis.test("use registers a backend that receives every notify, style and name", () => {
|
|
26
|
+
let recorded = [];
|
|
27
|
+
let onProvisioned = (logStyle, name, param, param$1, param$2, param$3, param$4) => {
|
|
28
|
+
recorded.push([
|
|
29
|
+
logStyle,
|
|
30
|
+
name
|
|
31
|
+
]);
|
|
32
|
+
};
|
|
33
|
+
let Recorder = {
|
|
34
|
+
onProvisioned: onProvisioned
|
|
35
|
+
};
|
|
36
|
+
EventLogProvisioning$ReventlessCore.use(Recorder);
|
|
37
|
+
EventLogProvisioning$ReventlessCore.notify("Classic", "ProductEventLog", classicOwner, [], noOpts);
|
|
38
|
+
EventLogProvisioning$ReventlessCore.notify("Dcb", "CatalogDcbEventLog", dcbOwner, [], noOpts);
|
|
39
|
+
globalThis.expect(recorded).toEqual([
|
|
40
|
+
[
|
|
41
|
+
"Classic",
|
|
42
|
+
"ProductEventLog"
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
"Dcb",
|
|
46
|
+
"CatalogDcbEventLog"
|
|
47
|
+
]
|
|
48
|
+
]);
|
|
49
|
+
EventLogProvisioning$ReventlessCore.reset();
|
|
50
|
+
});
|
|
51
|
+
globalThis.test("notify forwards the owner and the adapter's resources unchanged", () => {
|
|
52
|
+
let seen = {
|
|
53
|
+
contents: undefined
|
|
54
|
+
};
|
|
55
|
+
let onProvisioned = (param, param$1, owner, param$2, param$3, resources, param$4) => {
|
|
56
|
+
seen.contents = [
|
|
57
|
+
owner,
|
|
58
|
+
resources
|
|
59
|
+
];
|
|
60
|
+
};
|
|
61
|
+
let Capture = {
|
|
62
|
+
onProvisioned: onProvisioned
|
|
63
|
+
};
|
|
64
|
+
EventLogProvisioning$ReventlessCore.use(Capture);
|
|
65
|
+
EventLogProvisioning$ReventlessCore.notify("Classic", "ProductEventLog", classicOwner, [stubResource], noOpts);
|
|
66
|
+
let match = seen.contents;
|
|
67
|
+
if (match !== undefined) {
|
|
68
|
+
globalThis.expect(match[0]).toEqual(classicOwner);
|
|
69
|
+
globalThis.expect(match[1]).toHaveLength(1);
|
|
70
|
+
} else {
|
|
71
|
+
globalThis.expect("no notify received").toBe("a notify");
|
|
72
|
+
}
|
|
73
|
+
EventLogProvisioning$ReventlessCore.reset();
|
|
74
|
+
});
|
|
75
|
+
globalThis.test("a Postgres-style log with no resources still fires — the contract is uniform", () => {
|
|
76
|
+
let styles = [];
|
|
77
|
+
let onProvisioned = (logStyle, param, param$1, param$2, param$3, resources, param$4) => {
|
|
78
|
+
if (resources.length === 0) {
|
|
79
|
+
styles.push(logStyle);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
let Recorder = {
|
|
84
|
+
onProvisioned: onProvisioned
|
|
85
|
+
};
|
|
86
|
+
EventLogProvisioning$ReventlessCore.use(Recorder);
|
|
87
|
+
EventLogProvisioning$ReventlessCore.notify("Dcb", "CatalogDcbEventLog", dcbOwner, [], noOpts);
|
|
88
|
+
globalThis.expect(styles).toEqual(["Dcb"]);
|
|
89
|
+
EventLogProvisioning$ReventlessCore.reset();
|
|
90
|
+
});
|
|
91
|
+
globalThis.test("notify delivers the ambient plugin/platform inside a construct scope, None outside", () => {
|
|
92
|
+
let seen = [];
|
|
93
|
+
let onProvisioned = (param, param$1, param$2, plugin, platform, param$3, param$4) => {
|
|
94
|
+
seen.push([
|
|
95
|
+
plugin,
|
|
96
|
+
platform
|
|
97
|
+
]);
|
|
98
|
+
};
|
|
99
|
+
let OwnerCapture = {
|
|
100
|
+
onProvisioned: onProvisioned
|
|
101
|
+
};
|
|
102
|
+
EventLogProvisioning$ReventlessCore.use(OwnerCapture);
|
|
103
|
+
EventLogProvisioning$ReventlessCore.notify("Classic", "ProductEventLog", classicOwner, [], noOpts);
|
|
104
|
+
let prev = ResourceAttribution$ReventlessCore.enter("online-shop", "Catalog");
|
|
105
|
+
EventLogProvisioning$ReventlessCore.notify("Dcb", "CatalogDcbEventLog", dcbOwner, [], noOpts);
|
|
106
|
+
ResourceAttribution$ReventlessCore.restore(prev);
|
|
107
|
+
EventLogProvisioning$ReventlessCore.notify("Classic", "OrderEventLog", undefined, [], noOpts);
|
|
108
|
+
globalThis.expect(seen).toEqual([
|
|
109
|
+
[
|
|
110
|
+
undefined,
|
|
111
|
+
undefined
|
|
112
|
+
],
|
|
113
|
+
[
|
|
114
|
+
"Catalog",
|
|
115
|
+
"online-shop"
|
|
116
|
+
],
|
|
117
|
+
[
|
|
118
|
+
undefined,
|
|
119
|
+
undefined
|
|
120
|
+
]
|
|
121
|
+
]);
|
|
122
|
+
EventLogProvisioning$ReventlessCore.reset();
|
|
123
|
+
});
|
|
124
|
+
globalThis.test("use is single-slot: a second registration throws rather than silently winning", () => {
|
|
125
|
+
let onProvisioned = (param, param$1, param$2, param$3, param$4, param$5, param$6) => {};
|
|
126
|
+
let First = {
|
|
127
|
+
onProvisioned: onProvisioned
|
|
128
|
+
};
|
|
129
|
+
EventLogProvisioning$ReventlessCore.use(First);
|
|
130
|
+
let threw;
|
|
131
|
+
try {
|
|
132
|
+
EventLogProvisioning$ReventlessCore.use(First);
|
|
133
|
+
threw = false;
|
|
134
|
+
} catch (exn) {
|
|
135
|
+
threw = true;
|
|
136
|
+
}
|
|
137
|
+
globalThis.expect(threw).toBe(true);
|
|
138
|
+
EventLogProvisioning$ReventlessCore.reset();
|
|
139
|
+
EventLogProvisioning$ReventlessCore.use(First);
|
|
140
|
+
EventLogProvisioning$ReventlessCore.reset();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
export {
|
|
145
|
+
stubResource,
|
|
146
|
+
noOpts,
|
|
147
|
+
classicOwner,
|
|
148
|
+
dcbOwner,
|
|
149
|
+
}
|
|
150
|
+
/* 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
|
+
})
|