@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.
@@ -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
+ })