@reventlessdev/reventless-core 3.0.0-alpha.153 → 3.0.0-alpha.155
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 +17 -0
- package/package.json +6 -6
- package/rescript.json +2 -1
- 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/tests/adapter/MonitoringTest.res +53 -0
- package/tests/adapter/MonitoringTest.res.mjs +82 -0
- package/tests/message/MessageTest.res +146 -0
- package/tests/message/MessageTest.res.mjs +127 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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.155 (2026-07-11)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **build:** mark tests as dev-only sources so dependents don't compile them ([28b3b1c](https://github.com/ReventlessDev/reventless-core/commit/28b3b1ccfeaafc1d7050a86ce2362f32e4299187))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.154 (2026-07-11)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **plugin-lifecycle:** heal message decode of definitions persisted before a schema field existed ([6bb3e72](https://github.com/ReventlessDev/reventless-core/commit/6bb3e7259ad606a0f77fb670bcfc680256592003))
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **monitoring:** deploy-time Monitoring hook seam for provisioned execution units ([30f1c23](https://github.com/ReventlessDev/reventless-core/commit/30f1c23ba118805dae83af9115341f4aff6db92b))
|
|
21
|
+
|
|
22
|
+
|
|
6
23
|
# 3.0.0-alpha.153 (2026-07-10)
|
|
7
24
|
|
|
8
25
|
### 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.155",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,17 +28,17 @@
|
|
|
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.
|
|
31
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.26",
|
|
32
32
|
"@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
|
|
33
33
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
|
|
34
|
-
"@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
|
|
35
34
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.6",
|
|
35
|
+
"@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
|
|
39
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
40
|
-
"@reventlessdev/reventless-interop": "3.0.0-alpha.
|
|
41
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
39
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.96",
|
|
40
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.25",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.74"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "^12.3.0",
|
package/rescript.json
CHANGED
package/src/Message.res
CHANGED
|
@@ -64,10 +64,16 @@ let toCommandSchema' = (idSchema, commandSchema) =>
|
|
|
64
64
|
})
|
|
65
65
|
)
|
|
66
66
|
|
|
67
|
+
// Schema-migration-on-read: reuse the tolerant parse from `Reventless.Message`
|
|
68
|
+
// (`parseJsonTolerant` / `fillMissingDefaults`, included above) so a message persisted
|
|
69
|
+
// before a nested `@schema` field existed still decodes instead of bricking the
|
|
70
|
+
// aggregate's log replay. These two entry points thread the memoized envelope schema;
|
|
71
|
+
// `Reventless.Message.decode` (the other central decoder) is tolerant at the source.
|
|
72
|
+
// See docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
|
|
67
73
|
let decodeEvent' = (json, idSchema, eventSchema) =>
|
|
68
|
-
json->
|
|
74
|
+
json->parseJsonTolerant(toEventSchema'(idSchema, eventSchema))
|
|
69
75
|
let decodeCommand' = (json, idSchema, commandSchema) =>
|
|
70
|
-
json->
|
|
76
|
+
json->parseJsonTolerant(toCommandSchema'(idSchema, commandSchema))
|
|
71
77
|
|
|
72
78
|
let encodeEvent' = (event', idSchema, eventSchema) =>
|
|
73
79
|
event'->S.reverseConvertToJsonOrThrow(toEventSchema'(idSchema, eventSchema))
|
package/src/Message.res.mjs
CHANGED
|
@@ -44,11 +44,11 @@ function toCommandSchema$p(idSchema, commandSchema) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function decodeEvent$p(json, idSchema, eventSchema) {
|
|
47
|
-
return
|
|
47
|
+
return Message$Reventless.parseJsonTolerant(json, toEventSchema$p(idSchema, eventSchema));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function decodeCommand$p(json, idSchema, commandSchema) {
|
|
51
|
-
return
|
|
51
|
+
return Message$Reventless.parseJsonTolerant(json, toCommandSchema$p(idSchema, commandSchema));
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function encodeEvent$p(event$p, idSchema, eventSchema) {
|
|
@@ -384,6 +384,10 @@ let statusChangeSchema = Message$Reventless.statusChangeSchema;
|
|
|
384
384
|
|
|
385
385
|
let commandJsonSchema = Message$Reventless.commandJsonSchema;
|
|
386
386
|
|
|
387
|
+
let fillMissingDefaults = Message$Reventless.fillMissingDefaults;
|
|
388
|
+
|
|
389
|
+
let parseJsonTolerant = Message$Reventless.parseJsonTolerant;
|
|
390
|
+
|
|
387
391
|
let decode = Message$Reventless.decode;
|
|
388
392
|
|
|
389
393
|
let encode = Message$Reventless.encode;
|
|
@@ -397,6 +401,8 @@ export {
|
|
|
397
401
|
contextSchema,
|
|
398
402
|
statusChangeSchema,
|
|
399
403
|
commandJsonSchema,
|
|
404
|
+
fillMissingDefaults,
|
|
405
|
+
parseJsonTolerant,
|
|
400
406
|
decode,
|
|
401
407
|
encode,
|
|
402
408
|
InvalidEvent,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Deploy-time inventory hook: every runtime backend announces the execution units
|
|
3
|
+
it provisions so an extension can attach provider-native monitoring to each.
|
|
4
|
+
|
|
5
|
+
No-op by default. Zero behavioral change unless an extension registers a backend
|
|
6
|
+
via `use` before the platform/plugin build. The seam fires once per unit at
|
|
7
|
+
provisioning time (inside the deploy program) — never at invocation, and it runs
|
|
8
|
+
no code in production. Monitoring/alerting itself is deliberately NOT a framework
|
|
9
|
+
concern; this only exposes the choke point through which the framework says
|
|
10
|
+
"I just provisioned an execution unit" and leaves what to do about it to the
|
|
11
|
+
listener.
|
|
12
|
+
|
|
13
|
+
See `docs/plans/monitoring-hook-seam.md`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
The ROLE of a provisioned execution unit, not its mechanism. What "failure"
|
|
18
|
+
means for a unit is provider-specific (a Lambda `Errors` metric vs. container
|
|
19
|
+
restarts vs. NATS redeliveries) and belongs in the extension's per-provider
|
|
20
|
+
`Backend`; core only says *that* a unit exists and *what role* it plays, which is
|
|
21
|
+
what keeps the contract stable across runtimes. `Other(string)` is the escape
|
|
22
|
+
hatch for support units and future roles (same pattern as the protocol's
|
|
23
|
+
`OtherKind`).
|
|
24
|
+
*/
|
|
25
|
+
type unitKind =
|
|
26
|
+
| /** aggregates, state-change slices, extension points — failure = writes
|
|
27
|
+
rejected/dropped (the silent-freeze class) */
|
|
28
|
+
CommandHandler
|
|
29
|
+
| /** state-view slices, read models — failure = silently stale reads */
|
|
30
|
+
Projection
|
|
31
|
+
| /** automation slices, side-effect handlers, translations — failure = missed
|
|
32
|
+
side effects at boundaries */
|
|
33
|
+
Reactor
|
|
34
|
+
| /** cross-plugin/admin event ingestion — failure = event flow between plugins
|
|
35
|
+
stops */
|
|
36
|
+
EventCollector
|
|
37
|
+
| /** scheduled task runners */
|
|
38
|
+
Task
|
|
39
|
+
| /** heartbeat/keep-alive — failure = lifecycle detection AND any staleness
|
|
40
|
+
watchdog go blind (monitoring's own pulse) */
|
|
41
|
+
Scheduler
|
|
42
|
+
| /** receives messages that exhausted processing */
|
|
43
|
+
DeadLetterSink
|
|
44
|
+
| /** support units (counters, change-feed relays, migration runners, query
|
|
45
|
+
resolvers, …) — providers pass their name */
|
|
46
|
+
Other(string)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
A monitoring backend, registered by an extension (a deploy program) before the
|
|
50
|
+
platform builds. `onProvisioned` is called once per execution unit.
|
|
51
|
+
|
|
52
|
+
`~name` is the unit's STATIC logical name — the string the builder passed to its
|
|
53
|
+
provisioning call. A Pulumi-based backend derives logical resource names from it
|
|
54
|
+
(alarm/rule resource names must be stable and known at graph-construction time).
|
|
55
|
+
`~component` is the deploy-time `Adapter.resource`; its fields are `Output`s
|
|
56
|
+
(physical, suffix-bearing — right for a metric dimension like a CloudWatch
|
|
57
|
+
FunctionName or a PrometheusRule deployment selector, but unusable as a logical
|
|
58
|
+
resource name). Because `onProvisioned` fires during resource creation inside the
|
|
59
|
+
deploy program, a Pulumi-based backend can create monitoring resources in the
|
|
60
|
+
same stack, parented naturally.
|
|
61
|
+
*/
|
|
62
|
+
module type Backend = {
|
|
63
|
+
let onProvisioned: (
|
|
64
|
+
~kind: unitKind,
|
|
65
|
+
~name: string,
|
|
66
|
+
~component: ReventlessInfra.Adapter.resource,
|
|
67
|
+
) => unit
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Default backend: does nothing. Active unless an extension calls `use`. */
|
|
71
|
+
module Noop: Backend = {
|
|
72
|
+
let onProvisioned = (~kind as _, ~name as _, ~component as _) => ()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let backend: ref<module(Backend)> = ref(module(Noop: Backend))
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
Register the monitoring backend. Must run before the platform/plugin build in the
|
|
79
|
+
deploy program (plain statement order — the registry is consulted lazily at each
|
|
80
|
+
provisioning site). Called once, by the extension, first.
|
|
81
|
+
*/
|
|
82
|
+
let use = (b: module(Backend)) => backend := b
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
Announce a provisioned execution unit to the registered backend. Called by
|
|
86
|
+
runtime backends at their provisioning sites (once per unit). No-op until an
|
|
87
|
+
extension registers a backend.
|
|
88
|
+
*/
|
|
89
|
+
let notify = (
|
|
90
|
+
~kind: unitKind,
|
|
91
|
+
~name: string,
|
|
92
|
+
~component: ReventlessInfra.Adapter.resource,
|
|
93
|
+
) => {
|
|
94
|
+
module B = unpack(backend.contents)
|
|
95
|
+
B.onProvisioned(~kind, ~name, ~component)
|
|
96
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function onProvisioned(param, param$1, param$2) {
|
|
5
|
+
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
let Noop = {
|
|
9
|
+
onProvisioned: onProvisioned
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let backend = {
|
|
13
|
+
contents: Noop
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function use(b) {
|
|
17
|
+
backend.contents = b;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function notify(kind, name, component) {
|
|
21
|
+
let B = backend.contents;
|
|
22
|
+
B.onProvisioned(kind, name, component);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
Noop,
|
|
27
|
+
backend,
|
|
28
|
+
use,
|
|
29
|
+
notify,
|
|
30
|
+
}
|
|
31
|
+
/* No side effect */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// The registry only forwards `~component`; it never reads its fields. A
|
|
4
|
+
// structural stand-in keeps @pulumi/pulumi (whose Output.make would build a real
|
|
5
|
+
// resource) out of the Jest run — its ESM entry can't be imported under Jest.
|
|
6
|
+
let stubResource: ReventlessInfra.Adapter.resource = %raw(`{}`)
|
|
7
|
+
|
|
8
|
+
describe("Monitoring", () => {
|
|
9
|
+
testSync("Noop default: notify is silent and does not throw", () => {
|
|
10
|
+
// No backend registered — the default Noop backend swallows the call.
|
|
11
|
+
Monitoring.notify(~kind=CommandHandler, ~name="before", ~component=stubResource)
|
|
12
|
+
expect(true)->toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
testSync("use registers a backend that receives every notify, role and name", () => {
|
|
16
|
+
let recorded: array<(Monitoring.unitKind, string)> = []
|
|
17
|
+
module Recorder: Monitoring.Backend = {
|
|
18
|
+
let onProvisioned = (~kind, ~name, ~component as _) => recorded->Array.push((kind, name))
|
|
19
|
+
}
|
|
20
|
+
Monitoring.use(module(Recorder: Monitoring.Backend))
|
|
21
|
+
|
|
22
|
+
Monitoring.notify(~kind=CommandHandler, ~name="AllAggregatesCmdHandler", ~component=stubResource)
|
|
23
|
+
Monitoring.notify(~kind=Projection, ~name="AllStateViewSlices", ~component=stubResource)
|
|
24
|
+
Monitoring.notify(~kind=DeadLetterSink, ~name="DeadLetterQueue", ~component=stubResource)
|
|
25
|
+
Monitoring.notify(~kind=Other("Counter"), ~name="ProductCounter", ~component=stubResource)
|
|
26
|
+
|
|
27
|
+
expect(recorded)->toEqual([
|
|
28
|
+
(Monitoring.CommandHandler, "AllAggregatesCmdHandler"),
|
|
29
|
+
(Monitoring.Projection, "AllStateViewSlices"),
|
|
30
|
+
(Monitoring.DeadLetterSink, "DeadLetterQueue"),
|
|
31
|
+
(Monitoring.Other("Counter"), "ProductCounter"),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
// Restore the default so registry state doesn't leak to other cases.
|
|
35
|
+
Monitoring.use(module(Monitoring.Noop))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
testSync("notify forwards the static name and component resource to the backend", () => {
|
|
39
|
+
let seen: ref<option<(string, ReventlessInfra.Adapter.resource)>> = ref(None)
|
|
40
|
+
module Capture: Monitoring.Backend = {
|
|
41
|
+
let onProvisioned = (~kind as _, ~name, ~component) => seen := Some((name, component))
|
|
42
|
+
}
|
|
43
|
+
Monitoring.use(module(Capture: Monitoring.Backend))
|
|
44
|
+
|
|
45
|
+
Monitoring.notify(~kind=Scheduler, ~name="HeartbeatUnit", ~component=stubResource)
|
|
46
|
+
switch seen.contents {
|
|
47
|
+
| Some((name, _component)) => expect(name)->toBe("HeartbeatUnit")
|
|
48
|
+
| None => expect("no notify received")->toBe("a notify")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
Monitoring.use(module(Monitoring.Noop))
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Monitoring$ReventlessCore from "../../src/adapter/Monitoring/Monitoring.res.mjs";
|
|
4
|
+
|
|
5
|
+
let stubResource = {};
|
|
6
|
+
|
|
7
|
+
globalThis.describe("Monitoring", () => {
|
|
8
|
+
globalThis.test("Noop default: notify is silent and does not throw", () => {
|
|
9
|
+
Monitoring$ReventlessCore.notify("CommandHandler", "before", stubResource);
|
|
10
|
+
globalThis.expect(true).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
globalThis.test("use registers a backend that receives every notify, role and name", () => {
|
|
13
|
+
let recorded = [];
|
|
14
|
+
let onProvisioned = (kind, name, param) => {
|
|
15
|
+
recorded.push([
|
|
16
|
+
kind,
|
|
17
|
+
name
|
|
18
|
+
]);
|
|
19
|
+
};
|
|
20
|
+
let Recorder = {
|
|
21
|
+
onProvisioned: onProvisioned
|
|
22
|
+
};
|
|
23
|
+
Monitoring$ReventlessCore.use(Recorder);
|
|
24
|
+
Monitoring$ReventlessCore.notify("CommandHandler", "AllAggregatesCmdHandler", stubResource);
|
|
25
|
+
Monitoring$ReventlessCore.notify("Projection", "AllStateViewSlices", stubResource);
|
|
26
|
+
Monitoring$ReventlessCore.notify("DeadLetterSink", "DeadLetterQueue", stubResource);
|
|
27
|
+
Monitoring$ReventlessCore.notify({
|
|
28
|
+
TAG: "Other",
|
|
29
|
+
_0: "Counter"
|
|
30
|
+
}, "ProductCounter", stubResource);
|
|
31
|
+
globalThis.expect(recorded).toEqual([
|
|
32
|
+
[
|
|
33
|
+
"CommandHandler",
|
|
34
|
+
"AllAggregatesCmdHandler"
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
"Projection",
|
|
38
|
+
"AllStateViewSlices"
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
"DeadLetterSink",
|
|
42
|
+
"DeadLetterQueue"
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
{
|
|
46
|
+
TAG: "Other",
|
|
47
|
+
_0: "Counter"
|
|
48
|
+
},
|
|
49
|
+
"ProductCounter"
|
|
50
|
+
]
|
|
51
|
+
]);
|
|
52
|
+
Monitoring$ReventlessCore.use(Monitoring$ReventlessCore.Noop);
|
|
53
|
+
});
|
|
54
|
+
globalThis.test("notify forwards the static name and component resource to the backend", () => {
|
|
55
|
+
let seen = {
|
|
56
|
+
contents: undefined
|
|
57
|
+
};
|
|
58
|
+
let onProvisioned = (param, name, component) => {
|
|
59
|
+
seen.contents = [
|
|
60
|
+
name,
|
|
61
|
+
component
|
|
62
|
+
];
|
|
63
|
+
};
|
|
64
|
+
let Capture = {
|
|
65
|
+
onProvisioned: onProvisioned
|
|
66
|
+
};
|
|
67
|
+
Monitoring$ReventlessCore.use(Capture);
|
|
68
|
+
Monitoring$ReventlessCore.notify("Scheduler", "HeartbeatUnit", stubResource);
|
|
69
|
+
let match = seen.contents;
|
|
70
|
+
if (match !== undefined) {
|
|
71
|
+
globalThis.expect(match[0]).toBe("HeartbeatUnit");
|
|
72
|
+
} else {
|
|
73
|
+
globalThis.expect("no notify received").toBe("a notify");
|
|
74
|
+
}
|
|
75
|
+
Monitoring$ReventlessCore.use(Monitoring$ReventlessCore.Noop);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
stubResource,
|
|
81
|
+
}
|
|
82
|
+
/* Not a pure module */
|
|
@@ -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 */
|