@reventlessdev/reventless-core 3.0.0-alpha.152 → 3.0.0-alpha.154
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 +18 -0
- package/package.json +6 -6
- package/src/Message.res +8 -2
- package/src/Message.res.mjs +8 -2
- package/src/adapter/Monitoring/Monitoring.res +96 -0
- package/src/adapter/Monitoring/Monitoring.res.mjs +31 -0
- package/src/admin/Platform_Admin_Structure.res +2 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +4 -2
- package/src/admin/Platform_ComponentDefinitionsApi.res +11 -0
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +28 -0
- package/src/plugin/component/Plugin.res +1 -0
- package/src/plugin/component/Plugin_Builder.res +40 -1
- package/src/plugin/component/Plugin_Builder.res.mjs +37 -9
- package/src/plugin/component/Plugin_BuiltHook.res +11 -0
- package/src/plugin/component/Plugin_Structure.res +15 -0
- package/src/plugin/component/Plugin_Structure.res.mjs +16 -8
- package/tests/adapter/MonitoringTest.res +53 -0
- package/tests/adapter/MonitoringTest.res.mjs +82 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +69 -1
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +64 -8
- package/tests/message/MessageTest.res +146 -0
- package/tests/message/MessageTest.res.mjs +127 -0
- package/tests/plugin/ManifestVisibilityTest.res.mjs +1 -1
- package/tests/plugin/PluginStructureTest.res +34 -0
- package/tests/plugin/PluginStructureTest.res.mjs +32 -1
|
@@ -93,4 +93,150 @@ describe("Message should", () => {
|
|
|
93
93
|
|
|
94
94
|
expect(eventId)->toBe("testId")
|
|
95
95
|
})
|
|
96
|
+
|
|
97
|
+
// Schema-migration-on-read: a VersionConnected event persisted before `kind` (and
|
|
98
|
+
// before later pluginStructure fields like `events`/`chapter`) existed must still
|
|
99
|
+
// decode — otherwise the one stale event bricks the whole lifecycle aggregate. See
|
|
100
|
+
// docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
|
|
101
|
+
testSync("tolerantly decode a VersionConnected persisted before kind/structure fields existed", () => {
|
|
102
|
+
open PluginSpec
|
|
103
|
+
let def: Reventless.Plugin.pluginDefinition = {
|
|
104
|
+
id: "id",
|
|
105
|
+
name: "Ordering",
|
|
106
|
+
version: "1.0.0",
|
|
107
|
+
extensionPoints: [],
|
|
108
|
+
extensions: [],
|
|
109
|
+
eventCollector: "arn",
|
|
110
|
+
extensionProtocols: [],
|
|
111
|
+
apiSchemaFragment: None,
|
|
112
|
+
apiTarget: None,
|
|
113
|
+
uiFragments: None,
|
|
114
|
+
structure: Some({
|
|
115
|
+
readModels: [],
|
|
116
|
+
stateViewSlices: [],
|
|
117
|
+
stateChangeSlices: [],
|
|
118
|
+
aggregates: [
|
|
119
|
+
{
|
|
120
|
+
name: "Order",
|
|
121
|
+
commands: [],
|
|
122
|
+
producedEventTypes: [],
|
|
123
|
+
consumedEventTypes: [],
|
|
124
|
+
linkedViews: [],
|
|
125
|
+
consistencyRead: None,
|
|
126
|
+
events: [],
|
|
127
|
+
chapter: None,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
automationSlices: [],
|
|
131
|
+
outboundTranslationSlices: [],
|
|
132
|
+
inboundTranslationSlices: [],
|
|
133
|
+
extensions: [],
|
|
134
|
+
extensionPoints: None,
|
|
135
|
+
}),
|
|
136
|
+
dcbEventLog: None,
|
|
137
|
+
kind: Domain,
|
|
138
|
+
}
|
|
139
|
+
let event' = {
|
|
140
|
+
id: "id",
|
|
141
|
+
meta: {
|
|
142
|
+
service: "svc",
|
|
143
|
+
time: "t",
|
|
144
|
+
ip: "ip",
|
|
145
|
+
user: "u",
|
|
146
|
+
msgId: "m",
|
|
147
|
+
correlationId: "c",
|
|
148
|
+
},
|
|
149
|
+
event: VersionConnected(def),
|
|
150
|
+
}
|
|
151
|
+
let json = event'->Message.encodeEvent'(S.string, PluginSpec.eventSchema)
|
|
152
|
+
|
|
153
|
+
// Simulate the persisted JSON as written before these fields existed: drop the
|
|
154
|
+
// mandatory enum `kind`, a top-level `T | null` field, and two nested writableDef
|
|
155
|
+
// fields (a mandatory array + a `T | null`).
|
|
156
|
+
let oldJson =
|
|
157
|
+
%raw(`function(j){
|
|
158
|
+
var c = JSON.parse(JSON.stringify(j));
|
|
159
|
+
var d = c.event._0;
|
|
160
|
+
delete d.kind;
|
|
161
|
+
delete d.apiSchemaFragment;
|
|
162
|
+
var agg = d.structure.aggregates[0];
|
|
163
|
+
delete agg.events;
|
|
164
|
+
delete agg.chapter;
|
|
165
|
+
return c;
|
|
166
|
+
}`)(json)
|
|
167
|
+
|
|
168
|
+
// Strict decode must reject the stale JSON — the tolerance is what saves it.
|
|
169
|
+
let strictThrew = switch oldJson->S.parseJsonOrThrow(
|
|
170
|
+
Message.toEventSchema'(S.string, PluginSpec.eventSchema),
|
|
171
|
+
) {
|
|
172
|
+
| _ => false
|
|
173
|
+
| exception _ => true
|
|
174
|
+
}
|
|
175
|
+
expect(strictThrew)->toBe(true)
|
|
176
|
+
|
|
177
|
+
// Tolerant decode heals: kind -> Domain (first variant), missing array -> [],
|
|
178
|
+
// missing `T | null` fields -> None.
|
|
179
|
+
let decoded = oldJson->Message.decodeEvent'(S.string, PluginSpec.eventSchema)
|
|
180
|
+
switch decoded.event {
|
|
181
|
+
| VersionConnected(d) =>
|
|
182
|
+
expect(
|
|
183
|
+
switch d.kind {
|
|
184
|
+
| Domain => "Domain"
|
|
185
|
+
| _ => "other"
|
|
186
|
+
},
|
|
187
|
+
)->toBe("Domain")
|
|
188
|
+
expect(d.apiSchemaFragment->Option.isNone)->toBe(true)
|
|
189
|
+
let structure: Reventless.Plugin.pluginStructure = d.structure->Option.getOrThrow
|
|
190
|
+
let agg = structure.aggregates->Array.getUnsafe(0)
|
|
191
|
+
expect(agg.events->Array.length)->toBe(0)
|
|
192
|
+
expect(agg.chapter->Option.isNone)->toBe(true)
|
|
193
|
+
| _ => expect("wrong-variant")->toBe("VersionConnected")
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// The exact aggregate-replay path: EventLog_Operations.decodeEvent reassembles the
|
|
198
|
+
// event variant and calls `Message.decode(Spec.eventSchema)`. This is what actually
|
|
199
|
+
// bricked in production, so assert `Message.decode` itself heals a stale def.
|
|
200
|
+
testSync("Message.decode heals a VersionConnected variant missing kind (the replay path)", () => {
|
|
201
|
+
open PluginSpec
|
|
202
|
+
let def: Reventless.Plugin.pluginDefinition = {
|
|
203
|
+
id: "id",
|
|
204
|
+
name: "Ordering",
|
|
205
|
+
version: "1.0.0",
|
|
206
|
+
extensionPoints: [],
|
|
207
|
+
extensions: [],
|
|
208
|
+
eventCollector: "arn",
|
|
209
|
+
extensionProtocols: [],
|
|
210
|
+
apiSchemaFragment: None,
|
|
211
|
+
apiTarget: None,
|
|
212
|
+
uiFragments: None,
|
|
213
|
+
structure: None,
|
|
214
|
+
dcbEventLog: None,
|
|
215
|
+
kind: Domain,
|
|
216
|
+
}
|
|
217
|
+
let variantJson = VersionConnected(def)->Message.encode(PluginSpec.eventSchema)
|
|
218
|
+
let staleJson =
|
|
219
|
+
%raw(`function(j){ var c = JSON.parse(JSON.stringify(j)); delete c._0.kind; return c; }`)(
|
|
220
|
+
variantJson,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// Strict decode of the stale variant throws (reproduces the production brick).
|
|
224
|
+
let strictThrew = switch staleJson->S.parseJsonOrThrow(PluginSpec.eventSchema) {
|
|
225
|
+
| _ => false
|
|
226
|
+
| exception _ => true
|
|
227
|
+
}
|
|
228
|
+
expect(strictThrew)->toBe(true)
|
|
229
|
+
|
|
230
|
+
// The replay decoder heals kind -> Domain.
|
|
231
|
+
switch staleJson->Message.decode(PluginSpec.eventSchema) {
|
|
232
|
+
| VersionConnected(d) =>
|
|
233
|
+
expect(
|
|
234
|
+
switch d.kind {
|
|
235
|
+
| Domain => "Domain"
|
|
236
|
+
| _ => "other"
|
|
237
|
+
},
|
|
238
|
+
)->toBe("Domain")
|
|
239
|
+
| _ => expect("wrong-variant")->toBe("VersionConnected")
|
|
240
|
+
}
|
|
241
|
+
})
|
|
96
242
|
})
|
|
@@ -89,6 +89,133 @@ globalThis.describe("Message should", () => {
|
|
|
89
89
|
let eventId = Stdlib_Option.getOrThrow(Message$ReventlessCore.idOfEvent$pJson(eventJson$p), undefined);
|
|
90
90
|
globalThis.expect(eventId).toBe("testId");
|
|
91
91
|
});
|
|
92
|
+
globalThis.test("tolerantly decode a VersionConnected persisted before kind/structure fields existed", () => {
|
|
93
|
+
let def = {
|
|
94
|
+
id: "id",
|
|
95
|
+
name: "Ordering",
|
|
96
|
+
version: "1.0.0",
|
|
97
|
+
extensionPoints: [],
|
|
98
|
+
extensions: [],
|
|
99
|
+
eventCollector: "arn",
|
|
100
|
+
extensionProtocols: [],
|
|
101
|
+
apiSchemaFragment: undefined,
|
|
102
|
+
apiTarget: undefined,
|
|
103
|
+
uiFragments: undefined,
|
|
104
|
+
structure: {
|
|
105
|
+
readModels: [],
|
|
106
|
+
stateViewSlices: [],
|
|
107
|
+
stateChangeSlices: [],
|
|
108
|
+
aggregates: [{
|
|
109
|
+
name: "Order",
|
|
110
|
+
commands: [],
|
|
111
|
+
producedEventTypes: [],
|
|
112
|
+
consumedEventTypes: [],
|
|
113
|
+
linkedViews: [],
|
|
114
|
+
consistencyRead: undefined,
|
|
115
|
+
events: [],
|
|
116
|
+
chapter: undefined
|
|
117
|
+
}],
|
|
118
|
+
automationSlices: [],
|
|
119
|
+
outboundTranslationSlices: [],
|
|
120
|
+
inboundTranslationSlices: [],
|
|
121
|
+
extensions: [],
|
|
122
|
+
extensionPoints: undefined
|
|
123
|
+
},
|
|
124
|
+
dcbEventLog: undefined,
|
|
125
|
+
kind: "Domain"
|
|
126
|
+
};
|
|
127
|
+
let event$p_meta = {
|
|
128
|
+
service: "svc",
|
|
129
|
+
time: "t",
|
|
130
|
+
ip: "ip",
|
|
131
|
+
user: "u",
|
|
132
|
+
msgId: "m",
|
|
133
|
+
correlationId: "c"
|
|
134
|
+
};
|
|
135
|
+
let event$p_event = {
|
|
136
|
+
TAG: "VersionConnected",
|
|
137
|
+
_0: def
|
|
138
|
+
};
|
|
139
|
+
let event$p = {
|
|
140
|
+
id: "id",
|
|
141
|
+
meta: event$p_meta,
|
|
142
|
+
event: event$p_event
|
|
143
|
+
};
|
|
144
|
+
let json = Message$ReventlessCore.encodeEvent$p(event$p, S.string, PluginSpec$ReventlessCore.eventSchema);
|
|
145
|
+
let oldJson = (function(j){
|
|
146
|
+
var c = JSON.parse(JSON.stringify(j));
|
|
147
|
+
var d = c.event._0;
|
|
148
|
+
delete d.kind;
|
|
149
|
+
delete d.apiSchemaFragment;
|
|
150
|
+
var agg = d.structure.aggregates[0];
|
|
151
|
+
delete agg.events;
|
|
152
|
+
delete agg.chapter;
|
|
153
|
+
return c;
|
|
154
|
+
})(json);
|
|
155
|
+
let strictThrew;
|
|
156
|
+
try {
|
|
157
|
+
S.parseJsonOrThrow(oldJson, Message$ReventlessCore.toEventSchema$p(S.string, PluginSpec$ReventlessCore.eventSchema));
|
|
158
|
+
strictThrew = false;
|
|
159
|
+
} catch (exn) {
|
|
160
|
+
strictThrew = true;
|
|
161
|
+
}
|
|
162
|
+
globalThis.expect(strictThrew).toBe(true);
|
|
163
|
+
let decoded = Message$ReventlessCore.decodeEvent$p(oldJson, S.string, PluginSpec$ReventlessCore.eventSchema);
|
|
164
|
+
let d = decoded.event;
|
|
165
|
+
if (d.TAG === "VersionConnected") {
|
|
166
|
+
let d$1 = d._0;
|
|
167
|
+
let match = d$1.kind;
|
|
168
|
+
let tmp;
|
|
169
|
+
tmp = match === "Domain" ? "Domain" : "other";
|
|
170
|
+
globalThis.expect(tmp).toBe("Domain");
|
|
171
|
+
globalThis.expect(Stdlib_Option.isNone(d$1.apiSchemaFragment)).toBe(true);
|
|
172
|
+
let structure = Stdlib_Option.getOrThrow(d$1.structure, undefined);
|
|
173
|
+
let agg = structure.aggregates[0];
|
|
174
|
+
globalThis.expect(agg.events.length).toBe(0);
|
|
175
|
+
globalThis.expect(Stdlib_Option.isNone(agg.chapter)).toBe(true);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
globalThis.expect("wrong-variant").toBe("VersionConnected");
|
|
179
|
+
});
|
|
180
|
+
globalThis.test("Message.decode heals a VersionConnected variant missing kind (the replay path)", () => {
|
|
181
|
+
let def = {
|
|
182
|
+
id: "id",
|
|
183
|
+
name: "Ordering",
|
|
184
|
+
version: "1.0.0",
|
|
185
|
+
extensionPoints: [],
|
|
186
|
+
extensions: [],
|
|
187
|
+
eventCollector: "arn",
|
|
188
|
+
extensionProtocols: [],
|
|
189
|
+
apiSchemaFragment: undefined,
|
|
190
|
+
apiTarget: undefined,
|
|
191
|
+
uiFragments: undefined,
|
|
192
|
+
structure: undefined,
|
|
193
|
+
dcbEventLog: undefined,
|
|
194
|
+
kind: "Domain"
|
|
195
|
+
};
|
|
196
|
+
let variantJson = Message$ReventlessCore.encode({
|
|
197
|
+
TAG: "VersionConnected",
|
|
198
|
+
_0: def
|
|
199
|
+
}, PluginSpec$ReventlessCore.eventSchema);
|
|
200
|
+
let staleJson = (function(j){ var c = JSON.parse(JSON.stringify(j)); delete c._0.kind; return c; })(variantJson);
|
|
201
|
+
let strictThrew;
|
|
202
|
+
try {
|
|
203
|
+
S.parseJsonOrThrow(staleJson, PluginSpec$ReventlessCore.eventSchema);
|
|
204
|
+
strictThrew = false;
|
|
205
|
+
} catch (exn) {
|
|
206
|
+
strictThrew = true;
|
|
207
|
+
}
|
|
208
|
+
globalThis.expect(strictThrew).toBe(true);
|
|
209
|
+
let d = Message$ReventlessCore.decode(staleJson, PluginSpec$ReventlessCore.eventSchema);
|
|
210
|
+
if (d.TAG === "VersionConnected") {
|
|
211
|
+
let match = d._0.kind;
|
|
212
|
+
let tmp;
|
|
213
|
+
tmp = match === "Domain" ? "Domain" : "other";
|
|
214
|
+
globalThis.expect(tmp).toBe("Domain");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
globalThis.expect("wrong-variant").toBe("VersionConnected");
|
|
218
|
+
});
|
|
92
219
|
});
|
|
93
220
|
|
|
94
221
|
/* Not a pure module */
|
|
@@ -177,7 +177,7 @@ globalThis.describe("ReadModel visibility filter", () => {
|
|
|
177
177
|
});
|
|
178
178
|
|
|
179
179
|
globalThis.describe("Plugin_Structure.make — visibility tagging", () => {
|
|
180
|
-
let structure = Plugin_Structure$ReventlessCore.make("VisibilityPlugin", undefined, readModels, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
180
|
+
let structure = Plugin_Structure$ReventlessCore.make("VisibilityPlugin", undefined, readModels, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
181
181
|
globalThis.test("readModels carries both Public and Internal entries", () => {
|
|
182
182
|
globalThis.expect(structure.readModels.length).toBe(2);
|
|
183
183
|
});
|
|
@@ -295,4 +295,38 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
295
295
|
)->toBe(None)
|
|
296
296
|
})
|
|
297
297
|
})
|
|
298
|
+
|
|
299
|
+
// The generator captures each component's chapter (source-folder grouping band)
|
|
300
|
+
// and passes it as ~componentChapters; Plugin_Structure looks it up by Spec.name
|
|
301
|
+
// and threads it onto every def, so a deployed-graph consumer can render chapter
|
|
302
|
+
// bands without workspace access. See docs/plans/deployed-chapter-grouping.md.
|
|
303
|
+
describe("componentChapters threading", () => {
|
|
304
|
+
let chaptered = Plugin_Structure.make(
|
|
305
|
+
~name="TestPlugin",
|
|
306
|
+
~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
|
|
307
|
+
~stateViewSlices=[module(PsOrdersViewSlice)],
|
|
308
|
+
// PlaceOrder + Orders live under a chapter; ShipOrder does not.
|
|
309
|
+
~componentChapters=Dict.fromArray([("PlaceOrder", "Order"), ("Orders", "Order")]),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
testSync("a chaptered state-change slice carries Some(chapter)", () => {
|
|
313
|
+
let placeOrder = chaptered.stateChangeSlices->Array.getUnsafe(0)
|
|
314
|
+
expect(placeOrder.chapter)->toEqual(Some("Order"))
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
testSync("a chaptered state-view slice carries Some(chapter)", () => {
|
|
318
|
+
let orders = chaptered.stateViewSlices->Array.getUnsafe(0)
|
|
319
|
+
expect(orders.chapter)->toEqual(Some("Order"))
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
testSync("a component absent from the map carries None", () => {
|
|
323
|
+
let shipOrder = chaptered.stateChangeSlices->Array.getUnsafe(1)
|
|
324
|
+
expect(shipOrder.chapter)->toEqual(None)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
testSync("no ~componentChapters → every def is chapterless (None)", () => {
|
|
328
|
+
let placeOrder = structure.stateChangeSlices->Array.getUnsafe(0)
|
|
329
|
+
expect(placeOrder.chapter)->toEqual(None)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
298
332
|
})
|
|
@@ -183,7 +183,7 @@ let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, un
|
|
|
183
183
|
], [
|
|
184
184
|
PsPlaceOrderSlice,
|
|
185
185
|
PsShipOrderSlice
|
|
186
|
-
], undefined, undefined, undefined, undefined, undefined);
|
|
186
|
+
], undefined, undefined, undefined, undefined, undefined, undefined);
|
|
187
187
|
|
|
188
188
|
globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
189
189
|
globalThis.describe("stateChangeSlices", () => {
|
|
@@ -374,6 +374,37 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
374
374
|
globalThis.expect(Stdlib_Option.flatMap(orderIdField, s => getProperty(s, "x-reventless-id"))).toBe(undefined);
|
|
375
375
|
});
|
|
376
376
|
});
|
|
377
|
+
globalThis.describe("componentChapters threading", () => {
|
|
378
|
+
let chaptered = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [PsOrdersViewSlice], [
|
|
379
|
+
PsPlaceOrderSlice,
|
|
380
|
+
PsShipOrderSlice
|
|
381
|
+
], undefined, undefined, undefined, undefined, undefined, Object.fromEntries([
|
|
382
|
+
[
|
|
383
|
+
"PlaceOrder",
|
|
384
|
+
"Order"
|
|
385
|
+
],
|
|
386
|
+
[
|
|
387
|
+
"Orders",
|
|
388
|
+
"Order"
|
|
389
|
+
]
|
|
390
|
+
]));
|
|
391
|
+
globalThis.test("a chaptered state-change slice carries Some(chapter)", () => {
|
|
392
|
+
let placeOrder = chaptered.stateChangeSlices[0];
|
|
393
|
+
globalThis.expect(placeOrder.chapter).toEqual("Order");
|
|
394
|
+
});
|
|
395
|
+
globalThis.test("a chaptered state-view slice carries Some(chapter)", () => {
|
|
396
|
+
let orders = chaptered.stateViewSlices[0];
|
|
397
|
+
globalThis.expect(orders.chapter).toEqual("Order");
|
|
398
|
+
});
|
|
399
|
+
globalThis.test("a component absent from the map carries None", () => {
|
|
400
|
+
let shipOrder = chaptered.stateChangeSlices[1];
|
|
401
|
+
globalThis.expect(shipOrder.chapter).toEqual(undefined);
|
|
402
|
+
});
|
|
403
|
+
globalThis.test("no ~componentChapters → every def is chapterless (None)", () => {
|
|
404
|
+
let placeOrder = structure.stateChangeSlices[0];
|
|
405
|
+
globalThis.expect(placeOrder.chapter).toEqual(undefined);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
377
408
|
});
|
|
378
409
|
|
|
379
410
|
export {
|