@reventlessdev/reventless-local 3.0.0-alpha.301 → 3.0.0-alpha.302
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 +22 -0
- package/package.json +8 -8
- package/src/EmitCapabilities.res +12 -5
- package/src/EmitCapabilities.res.mjs +13 -1
- package/src/Platform.res +0 -1
- package/src/Platform.res.mjs +2 -4
- package/tests/EmitCapabilitiesTest.res +6 -0
- package/tests/EmitCapabilitiesTest.res.mjs +6 -0
- package/tests/components/dcb/DcbRepeatTest.res +291 -0
- package/tests/components/dcb/DcbRepeatTest.res.mjs +420 -0
- package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res +71 -15
- package/tests/components/readmodel/DcbReadModelTypoFailFastTest.res.mjs +119 -14
- package/tests/plugin/Plugin_Fixtures.res +1 -3
- package/tests/plugin/Plugin_Fixtures.res.mjs +1 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
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.302 (2026-10-29)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **local:** emit-capabilities writes an empty manifest for a plugin with no parts yet ([56586a6](https://github.com/ReventlessDev/reventless-core/commit/56586a687aa4658e2357c836878b44050b424d41))
|
|
11
|
+
* refactor!: drop `dcbSources`, the cross-plugin DCB subscription nobody declared ([cf3b66d](https://github.com/ReventlessDev/reventless-core/commit/cf3b66d8c1b5d87673197b64eaee174598bb8253))
|
|
12
|
+
* refactor!: a read model hears another plugin only through an Extension and a slice ([15c7cae](https://github.com/ReventlessDev/reventless-core/commit/15c7cae214d5aa04404779fcd87a8429aad1965c))
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **core:** a handler drops a command it has already handled ([605862b](https://github.com/ReventlessDev/reventless-core/commit/605862bba765b1501eb19d08f3924bf619159e21))
|
|
16
|
+
|
|
17
|
+
### BREAKING CHANGES
|
|
18
|
+
|
|
19
|
+
* `Reventless.Plugin.extensionDefinition` has no `dcbSources`
|
|
20
|
+
field, and `Platform_PluginExtensions.dcbSources` is gone from the GraphQL
|
|
21
|
+
schema. A query that selects it fails as a whole; drop the selection.
|
|
22
|
+
* `ReventlessInfra.Extension.T.make` and the core
|
|
23
|
+
`Extension_Builder.make` no longer take `~readModelNamesForSourceName` and
|
|
24
|
+
`~publishToReadModels`. Both arguments did nothing; drop them at the call site.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
6
28
|
# 3.0.0-alpha.301 (2026-10-25)
|
|
7
29
|
|
|
8
30
|
**Note:** Version bump only for package @reventlessdev/reventless-local
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-local",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.302",
|
|
4
4
|
"description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -37,16 +37,16 @@
|
|
|
37
37
|
"@reventlessdev/rescript-effect": "1.0.0-alpha.1",
|
|
38
38
|
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.29",
|
|
39
39
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.13",
|
|
40
|
-
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.24",
|
|
41
40
|
"@reventlessdev/rescript-node": "2.0.0-alpha.23",
|
|
41
|
+
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.24",
|
|
42
42
|
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
43
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
44
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
45
|
-
"@reventlessdev/reventless-
|
|
46
|
-
"@reventlessdev/reventless-
|
|
43
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.311",
|
|
44
|
+
"@reventlessdev/reventless-gwt": "1.0.0-alpha.259",
|
|
45
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.208",
|
|
46
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.175",
|
|
47
47
|
"@reventlessdev/reventless-seed": "1.0.0-alpha.38",
|
|
48
|
-
"@reventlessdev/reventless-
|
|
49
|
-
"@reventlessdev/reventless-
|
|
48
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.179",
|
|
49
|
+
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.159"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"rescript": "12.3.1",
|
package/src/EmitCapabilities.res
CHANGED
|
@@ -25,9 +25,19 @@
|
|
|
25
25
|
|
|
26
26
|
type platform
|
|
27
27
|
type localPlatformExports = {"Make": unit => platform}
|
|
28
|
-
|
|
28
|
+
// A plugin with no parts yet has no structure: `generate-plugin` leaves `pluginStructure` out of
|
|
29
|
+
// its composition root.
|
|
30
|
+
type builtPlugin = {"pluginStructure": Nullable.t<Reventless.Plugin.pluginStructure>}
|
|
29
31
|
type compositionExports = {"Make": platform => builtPlugin}
|
|
30
32
|
|
|
33
|
+
/** The manifest's text for a plugin's structure. A plugin with no parts yet has none and
|
|
34
|
+
declares nothing: its manifest says so, as one for a structure with no declarations does. */
|
|
35
|
+
let manifestText = (structure: option<Reventless.Plugin.pluginStructure>): string =>
|
|
36
|
+
switch structure {
|
|
37
|
+
| Some(structure) => Reventless.CapabilityManifest.renderForStructure(structure)
|
|
38
|
+
| None => Reventless.CapabilityManifest.render({capabilities: []})
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// ── Which module holds the composition root ──────────────────────────────────
|
|
32
42
|
|
|
33
43
|
/** `Plugin.res.mjs` is what `generate-plugin` emits, so it is the default and
|
|
@@ -99,10 +109,7 @@ let main = () =>
|
|
|
99
109
|
let built = composition["Make"](platform)
|
|
100
110
|
|
|
101
111
|
let manifestPath = NodePath.join([srcDir, "capabilities.json"])
|
|
102
|
-
NodeFs.writeFileSync(
|
|
103
|
-
manifestPath,
|
|
104
|
-
Reventless.CapabilityManifest.renderForStructure(built["pluginStructure"]),
|
|
105
|
-
)
|
|
112
|
+
NodeFs.writeFileSync(manifestPath, manifestText(built["pluginStructure"]->Nullable.toOption))
|
|
106
113
|
Console.log("Generated: " ++ manifestPath)
|
|
107
114
|
|
|
108
115
|
// Applying the platform functor wires in-process infrastructure; exit
|
|
@@ -4,9 +4,20 @@ import * as Nodefs from "node:fs";
|
|
|
4
4
|
import * as Nodeurl from "node:url";
|
|
5
5
|
import * as Nodepath from "node:path";
|
|
6
6
|
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
7
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
7
8
|
import * as CliArgs$Reventless from "@reventlessdev/reventless-spec/src/CliArgs.res.mjs";
|
|
8
9
|
import * as CapabilityManifest$Reventless from "@reventlessdev/reventless-spec/src/components/CapabilityManifest.res.mjs";
|
|
9
10
|
|
|
11
|
+
function manifestText(structure) {
|
|
12
|
+
if (structure !== undefined) {
|
|
13
|
+
return CapabilityManifest$Reventless.renderForStructure(structure);
|
|
14
|
+
} else {
|
|
15
|
+
return CapabilityManifest$Reventless.render({
|
|
16
|
+
capabilities: []
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
function compositionModulePath(srcDir, moduleArg) {
|
|
11
22
|
let withExtension = name => {
|
|
12
23
|
if (name.endsWith(".mjs")) {
|
|
@@ -84,13 +95,14 @@ function main() {
|
|
|
84
95
|
let composition = await import(Nodeurl.pathToFileURL(modulePath).href);
|
|
85
96
|
let built = composition.Make(platform);
|
|
86
97
|
let manifestPath = Nodepath.join(srcDir, "capabilities.json");
|
|
87
|
-
Nodefs.writeFileSync(manifestPath,
|
|
98
|
+
Nodefs.writeFileSync(manifestPath, manifestText(Primitive_option.fromNullable(built.pluginStructure)), "utf8");
|
|
88
99
|
console.log("Generated: " + manifestPath);
|
|
89
100
|
process.exit(0);
|
|
90
101
|
});
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
export {
|
|
105
|
+
manifestText,
|
|
94
106
|
compositionModulePath,
|
|
95
107
|
fail,
|
|
96
108
|
usage,
|
package/src/Platform.res
CHANGED
package/src/Platform.res.mjs
CHANGED
|
@@ -1063,8 +1063,7 @@ function MakeWithConfig(Config) {
|
|
|
1063
1063
|
let ext = param[1];
|
|
1064
1064
|
return {
|
|
1065
1065
|
name: ext.name,
|
|
1066
|
-
extensionPointName: ext.extensionPointName
|
|
1067
|
-
dcbSources: []
|
|
1066
|
+
extensionPointName: ext.extensionPointName
|
|
1068
1067
|
};
|
|
1069
1068
|
}),
|
|
1070
1069
|
eventCollector: param[2].name,
|
|
@@ -2858,8 +2857,7 @@ function Make($star) {
|
|
|
2858
2857
|
let ext = param[1];
|
|
2859
2858
|
return {
|
|
2860
2859
|
name: ext.name,
|
|
2861
|
-
extensionPointName: ext.extensionPointName
|
|
2862
|
-
dcbSources: []
|
|
2860
|
+
extensionPointName: ext.extensionPointName
|
|
2863
2861
|
};
|
|
2864
2862
|
}),
|
|
2865
2863
|
eventCollector: param[2].name,
|
|
@@ -19,6 +19,12 @@ describe("EmitCapabilities.parseArgs", () => {
|
|
|
19
19
|
)
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
+
describe("EmitCapabilities.manifestText", () => {
|
|
23
|
+
testSync("a plugin with no parts yet declares nothing", () =>
|
|
24
|
+
expect(EmitCapabilities.manifestText(None))->toBe(`{\n "capabilities": []\n}\n`)
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
22
28
|
describe("emit-capabilities on the command line", () => {
|
|
23
29
|
module CliArgs = Reventless.CliArgs
|
|
24
30
|
let usage = EmitCapabilities.usage->String.trim
|
|
@@ -30,6 +30,12 @@ globalThis.describe("EmitCapabilities.parseArgs", () => {
|
|
|
30
30
|
});
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
globalThis.describe("EmitCapabilities.manifestText", () => {
|
|
34
|
+
globalThis.test("a plugin with no parts yet declares nothing", () => {
|
|
35
|
+
globalThis.expect(EmitCapabilities$ReventlessLocal.manifestText(undefined)).toBe(`{\n "capabilities": []\n}\n`);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
33
39
|
globalThis.describe("emit-capabilities on the command line", () => {
|
|
34
40
|
let usage = EmitCapabilities$ReventlessLocal.usage.trim();
|
|
35
41
|
globalThis.test("--help anywhere prints the usage to stdout and exits 0", async () => {
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Another plugin's fact arrives through an Extension and a slice, and delivery is
|
|
2
|
+
// at-least-once. The slice here has no check of its own — every RecordDemand
|
|
3
|
+
// appends — so what keeps a redelivered ItemOrdered from counting twice is the
|
|
4
|
+
// framework: the Extension's command carries a stable id, and the handler drops a
|
|
5
|
+
// command the partition already holds events for. On both local backends, and
|
|
6
|
+
// for two concurrent copies.
|
|
7
|
+
|
|
8
|
+
open JestGlobals
|
|
9
|
+
|
|
10
|
+
let _ = TestRunner.setup()
|
|
11
|
+
let opts: Pulumi.CustomResourceOptions.t = {}
|
|
12
|
+
|
|
13
|
+
module Message = ReventlessCore.Message
|
|
14
|
+
|
|
15
|
+
// ─── The provider's extension point and the consumer's slice ────────────────
|
|
16
|
+
|
|
17
|
+
module Port = {
|
|
18
|
+
let name = "Ordering.Orders"
|
|
19
|
+
|
|
20
|
+
@schema
|
|
21
|
+
type command = Noop
|
|
22
|
+
|
|
23
|
+
@schema
|
|
24
|
+
type event = ItemOrdered({productId: string, orderId: string})
|
|
25
|
+
|
|
26
|
+
@schema
|
|
27
|
+
type directive = NoDirective
|
|
28
|
+
|
|
29
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module DemandSlice = {
|
|
33
|
+
let name = "RecordDemandNoCheck"
|
|
34
|
+
module Id = Reventless.Id.String
|
|
35
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
36
|
+
|
|
37
|
+
// The produced event joins a product and an order, so the partition is
|
|
38
|
+
// declared, as the shop's RecordProductDemand does.
|
|
39
|
+
@schema
|
|
40
|
+
type event =
|
|
41
|
+
DemandRecorded({productId: @s.matches(Reventless.DcbTag.partition) string, orderId: string})
|
|
42
|
+
|
|
43
|
+
@schema
|
|
44
|
+
type consumedEvent =
|
|
45
|
+
DemandRecorded({productId: @s.matches(Reventless.DcbTag.string) string, orderId: string})
|
|
46
|
+
|
|
47
|
+
@schema
|
|
48
|
+
type command =
|
|
49
|
+
RecordDemand({productId: @s.matches(Reventless.DcbTag.string) string, orderId: string})
|
|
50
|
+
|
|
51
|
+
@schema
|
|
52
|
+
type error = Never
|
|
53
|
+
|
|
54
|
+
type role = Reventless.Role.name
|
|
55
|
+
let authorizationOf = (_: string): Reventless.Authorization.rule<role> => AllowAuthenticated
|
|
56
|
+
type lifecycleState = unit
|
|
57
|
+
let commandTransition = (_: command): Reventless.Transition.t<lifecycleState> => Unrestricted
|
|
58
|
+
let traits: array<Reventless.Trait.t> = []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module DemandDecision = {
|
|
62
|
+
type state = {orders: array<string>}
|
|
63
|
+
let initialState = {orders: []}
|
|
64
|
+
let moduleUrl = DemandSlice.moduleUrl
|
|
65
|
+
|
|
66
|
+
let evolve = (state, event: DemandSlice.consumedEvent) =>
|
|
67
|
+
switch event {
|
|
68
|
+
| DemandRecorded({orderId}) => {orders: state.orders->Array.concat([orderId])}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Deliberately no "already counted" rule.
|
|
72
|
+
let decide = (_state, command: DemandSlice.command): result<
|
|
73
|
+
array<DemandSlice.event>,
|
|
74
|
+
DemandSlice.error,
|
|
75
|
+
> =>
|
|
76
|
+
switch command {
|
|
77
|
+
| RecordDemand({productId, orderId}) => Ok([DemandRecorded({productId, orderId})])
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module DemandHandler = ReventlessCore.StateChangeSlice_Callback.Make(DemandSlice, DemandDecision)
|
|
82
|
+
|
|
83
|
+
let partitionTag = Reventless.DcbTag.Simple({key: "productId"})
|
|
84
|
+
|
|
85
|
+
// The consumer's Extension, as the catalog's Orders_Extension is written.
|
|
86
|
+
module DemandExtension = ReventlessInfra.ExtensionMapping.Make({
|
|
87
|
+
module ExtensionPoint = Port
|
|
88
|
+
module Delegate = DemandSlice
|
|
89
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
90
|
+
let delegateModuleUrl = DemandSlice.moduleUrl
|
|
91
|
+
let mapIncomingEvent = (_id, event: Port.event, _meta, _pluginDef, _queryEngine) =>
|
|
92
|
+
switch event {
|
|
93
|
+
| ItemOrdered({productId, orderId}) => [
|
|
94
|
+
ReventlessInfra.ExtensionMapping.PublishStateChangeSliceCommand(
|
|
95
|
+
DemandSlice.RecordDemand({productId, orderId}),
|
|
96
|
+
),
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
let mapOutgoingEvent = None
|
|
100
|
+
let handledEvents = []
|
|
101
|
+
let issuedCommands = []
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
let queryEngine: Reventless.QueryEngine.operations = {
|
|
105
|
+
scan: async (~readModelName as _, ~filterConfigs as _, ~limit as _) => [],
|
|
106
|
+
query: async (
|
|
107
|
+
~readModelName as _,
|
|
108
|
+
~key as _=?,
|
|
109
|
+
~id as _,
|
|
110
|
+
~subIdConfig as _=?,
|
|
111
|
+
~filterConfigs as _=?,
|
|
112
|
+
~ascending as _=?,
|
|
113
|
+
~limit as _=?,
|
|
114
|
+
) => [],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let pluginDefinition: Reventless.Plugin.pluginDefinition = {
|
|
118
|
+
id: "Catalog@1",
|
|
119
|
+
name: "Catalog",
|
|
120
|
+
version: "1",
|
|
121
|
+
extensionPoints: [],
|
|
122
|
+
extensions: [{name: "Catalog", extensionPointName: Port.name}],
|
|
123
|
+
eventCollector: "eventCollector",
|
|
124
|
+
extensionProtocols: [],
|
|
125
|
+
apiSchemaFragment: None,
|
|
126
|
+
structure: None,
|
|
127
|
+
kind: Domain,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One published ItemOrdered, as the extension point stored and published it;
|
|
131
|
+
// every delivery of it carries this same envelope.
|
|
132
|
+
let published: Message.event'<string, Port.event> = {
|
|
133
|
+
id: "order-1",
|
|
134
|
+
meta: {
|
|
135
|
+
service: Port.name,
|
|
136
|
+
time: "2024-01-01T00:00:00Z",
|
|
137
|
+
msgId: "item-ordered-msg",
|
|
138
|
+
correlationId: "place-order-cmd",
|
|
139
|
+
causationId: "order-placed-event",
|
|
140
|
+
},
|
|
141
|
+
event: ItemOrdered({productId: "product-1", orderId: "order-1"}),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// What the Extension sends for one delivery of `published`.
|
|
145
|
+
let commandOf = (event': Message.event'<string, Port.event>) =>
|
|
146
|
+
switch DemandExtension.mapIncomingEvent(event', pluginDefinition, queryEngine) {
|
|
147
|
+
| [ReventlessInfra.ExtensionMapping.AbstractPublishAggregateCommand(_, cmdJson)] => cmdJson
|
|
148
|
+
| _ => JsError.throwWithMessage("expected one slice command")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── Backends ───────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
let eventLogOn = async (~name) => {
|
|
154
|
+
module Bus = LocalBus.Make()
|
|
155
|
+
module Storage = LocalDcbEventLogStorage.Make(Bus)
|
|
156
|
+
let s = Storage.make(~name, ~indexes=[], ~partitionTag, ~opts)
|
|
157
|
+
let storage = await s.operations->TestRunner.resolve
|
|
158
|
+
module Log = ReventlessCore.DcbEventLog_Operations.Make({
|
|
159
|
+
let name = name
|
|
160
|
+
let serviceName = name
|
|
161
|
+
let storage = storage
|
|
162
|
+
let publishJson = async (_, _, _) => ()
|
|
163
|
+
})
|
|
164
|
+
let eventLog: ReventlessCore.DcbEventLog.operations = {
|
|
165
|
+
read: Log.read,
|
|
166
|
+
append: Log.append,
|
|
167
|
+
readStream: Log.readStream,
|
|
168
|
+
appendStream: Log.appendStream,
|
|
169
|
+
}
|
|
170
|
+
eventLog
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let runUnderMemory = async fn => {
|
|
174
|
+
BackendState.setMemory()
|
|
175
|
+
await fn()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let runUnderSqlite = async fn => {
|
|
179
|
+
let db = SqliteDriver.openDb(~path=":memory:")
|
|
180
|
+
BackendState.setSqlite(~db, ~path=":memory:")
|
|
181
|
+
await fn()
|
|
182
|
+
BackendState.setMemory()
|
|
183
|
+
db->SqliteDriver.close
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let deliver = (eventLog, cmd: Message.commandJson) =>
|
|
187
|
+
DemandHandler.handleCommands(
|
|
188
|
+
~partitionTag,
|
|
189
|
+
eventLog,
|
|
190
|
+
Stream.fromIterable([
|
|
191
|
+
{
|
|
192
|
+
ReventlessInfra.CommandTopic.command: cmd
|
|
193
|
+
->ReventlessCore.CommandTopic_Helpers.encodeCommandJson
|
|
194
|
+
->Message.decodeCommand'(Reventless.Id.String.schema, DemandSlice.commandSchema),
|
|
195
|
+
reference: cmd.meta.msgId,
|
|
196
|
+
},
|
|
197
|
+
]),
|
|
198
|
+
)->Effect.runPromise
|
|
199
|
+
|
|
200
|
+
let recordedDemands = async (eventLog: ReventlessCore.DcbEventLog.operations) =>
|
|
201
|
+
(await eventLog.read(~query=[{eventTypes: ["DemandRecorded"]}])).events
|
|
202
|
+
|
|
203
|
+
let counter = ref(0)
|
|
204
|
+
let fresh = prefix => {
|
|
205
|
+
counter := counter.contents + 1
|
|
206
|
+
`${prefix}-${counter.contents->Int.toString}`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let _ = beforeEach(() => DemandHandler.resetCache())
|
|
210
|
+
|
|
211
|
+
let backends = [("in-memory", runUnderMemory), ("SQLite", runUnderSqlite)]
|
|
212
|
+
|
|
213
|
+
backends->Array.forEach(((label, runUnder)) =>
|
|
214
|
+
describe(`a redelivered fact is recorded once (${label} backend)`, () => {
|
|
215
|
+
testPromise(
|
|
216
|
+
"the same published event delivered twice appends one event",
|
|
217
|
+
async () =>
|
|
218
|
+
await runUnder(
|
|
219
|
+
async () => {
|
|
220
|
+
let log = await eventLogOn(~name=fresh("RepeatOnce"))
|
|
221
|
+
let first = await deliver(log, commandOf(published))
|
|
222
|
+
let again = await deliver(log, commandOf(published))
|
|
223
|
+
expect(first->Array.length)->toBe(1)
|
|
224
|
+
expect(again)->toEqual(first) // accepted both times
|
|
225
|
+
let events = await recordedDemands(log)
|
|
226
|
+
expect(events->Array.length)->toBe(1)
|
|
227
|
+
// The command is the stored event's cause, and the published event the command's.
|
|
228
|
+
let cmd = commandOf(published)
|
|
229
|
+
expect((events->Array.getUnsafe(0)).meta.causationId)->toEqual(Some(cmd.meta.msgId))
|
|
230
|
+
expect(cmd.meta.causationId)->toEqual(Some(published.meta.msgId))
|
|
231
|
+
},
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
testPromise(
|
|
236
|
+
"a different order for the same product still counts",
|
|
237
|
+
async () =>
|
|
238
|
+
await runUnder(
|
|
239
|
+
async () => {
|
|
240
|
+
let log = await eventLogOn(~name=fresh("RepeatOther"))
|
|
241
|
+
let _ = await deliver(log, commandOf(published))
|
|
242
|
+
let second: Message.event'<string, Port.event> = {
|
|
243
|
+
id: "order-2",
|
|
244
|
+
meta: {...published.meta, msgId: "item-ordered-msg-2"},
|
|
245
|
+
event: Port.ItemOrdered({productId: "product-1", orderId: "order-2"}),
|
|
246
|
+
}
|
|
247
|
+
let _ = await deliver(log, commandOf(second))
|
|
248
|
+
let _ = await deliver(log, commandOf(published))
|
|
249
|
+
let _ = await deliver(log, commandOf(second))
|
|
250
|
+
expect((await recordedDemands(log))->Array.length)->toBe(2)
|
|
251
|
+
},
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
testPromise(
|
|
256
|
+
"a command with a random id is decided every time",
|
|
257
|
+
async () =>
|
|
258
|
+
await runUnder(
|
|
259
|
+
async () => {
|
|
260
|
+
let log = await eventLogOn(~name=fresh("RepeatRandom"))
|
|
261
|
+
let cmd = commandOf(published)
|
|
262
|
+
// The envelope of a command nobody derived: fresh id, no mark.
|
|
263
|
+
let plain = {...cmd, meta: Message.deriveMeta(~parent=cmd.meta)}
|
|
264
|
+
let _ = await deliver(log, plain)
|
|
265
|
+
let _ = await deliver(log, plain)
|
|
266
|
+
expect((await recordedDemands(log))->Array.length)->toBe(2)
|
|
267
|
+
},
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
testPromise(
|
|
272
|
+
"two concurrent copies append one event",
|
|
273
|
+
async () =>
|
|
274
|
+
await runUnder(
|
|
275
|
+
async () => {
|
|
276
|
+
let log = await eventLogOn(~name=fresh("RepeatConcurrent"))
|
|
277
|
+
let cmd = commandOf(published)
|
|
278
|
+
// Both read an empty partition; the second append conflicts, re-reads,
|
|
279
|
+
// and finds the first one's event.
|
|
280
|
+
let (a, b) = await Promise.all2((deliver(log, cmd), deliver(log, cmd)))
|
|
281
|
+
expect(a->Array.length)->toBe(1)
|
|
282
|
+
expect(b->Array.length)->toBe(1)
|
|
283
|
+
expect(a->Array.getUnsafe(0)->Result.isOk && b->Array.getUnsafe(0)->Result.isOk)->toBe(
|
|
284
|
+
true,
|
|
285
|
+
)
|
|
286
|
+
expect((await recordedDemands(log))->Array.length)->toBe(1)
|
|
287
|
+
},
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
})
|
|
291
|
+
)
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
5
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import * as Stream from "effect/Stream";
|
|
8
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
9
|
+
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
10
|
+
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
11
|
+
import * as LocalBus$ReventlessLocal from "../../../src/adapter/LocalBus.res.mjs";
|
|
12
|
+
import * as TestRunner$ReventlessLocal from "../../../src/test/TestRunner.res.mjs";
|
|
13
|
+
import * as BackendState$ReventlessLocal from "../../../src/adapter/BackendState.res.mjs";
|
|
14
|
+
import * as SqliteDriver$ReventlessLocal from "../../../src/adapter/SqliteDriver.res.mjs";
|
|
15
|
+
import * as ExtensionMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionMapping.res.mjs";
|
|
16
|
+
import * as CommandTopic_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs";
|
|
17
|
+
import * as DcbEventLog_Operations$ReventlessCore from "@reventlessdev/reventless-core/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs";
|
|
18
|
+
import * as LocalDcbEventLogStorage$ReventlessLocal from "../../../src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs";
|
|
19
|
+
import * as StateChangeSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs";
|
|
20
|
+
|
|
21
|
+
TestRunner$ReventlessLocal.setup();
|
|
22
|
+
|
|
23
|
+
let opts = {};
|
|
24
|
+
|
|
25
|
+
let name = "Ordering.Orders";
|
|
26
|
+
|
|
27
|
+
let commandSchema = Sury.literal("Noop");
|
|
28
|
+
|
|
29
|
+
let eventSchema = Sury.$schema(s => ({
|
|
30
|
+
TAG: "ItemOrdered",
|
|
31
|
+
productId: s.m(Sury.string),
|
|
32
|
+
orderId: s.m(Sury.string)
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
let directiveSchema = Sury.literal("NoDirective");
|
|
36
|
+
|
|
37
|
+
let moduleUrl = import.meta.url;
|
|
38
|
+
|
|
39
|
+
function authorizationOf(param) {
|
|
40
|
+
return "AllowAuthenticated";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function commandTransition(param) {
|
|
44
|
+
return "Undeclared";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let traits = [];
|
|
48
|
+
|
|
49
|
+
let Port = {
|
|
50
|
+
name: name,
|
|
51
|
+
commandSchema: commandSchema,
|
|
52
|
+
eventSchema: eventSchema,
|
|
53
|
+
directiveSchema: directiveSchema,
|
|
54
|
+
moduleUrl: moduleUrl,
|
|
55
|
+
authorizationOf: authorizationOf,
|
|
56
|
+
commandTransition: commandTransition,
|
|
57
|
+
traits: traits
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let name$1 = "RecordDemandNoCheck";
|
|
61
|
+
|
|
62
|
+
let moduleUrl$1 = import.meta.url;
|
|
63
|
+
|
|
64
|
+
let eventSchema$1 = Sury.$schema(s => ({
|
|
65
|
+
TAG: "DemandRecorded",
|
|
66
|
+
productId: s.m(DcbTag$Reventless.partition),
|
|
67
|
+
orderId: s.m(Sury.string)
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
let consumedEventSchema = Sury.$schema(s => ({
|
|
71
|
+
TAG: "DemandRecorded",
|
|
72
|
+
productId: s.m(DcbTag$Reventless.string),
|
|
73
|
+
orderId: s.m(Sury.string)
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
let commandSchema$1 = Sury.$schema(s => ({
|
|
77
|
+
TAG: "RecordDemand",
|
|
78
|
+
productId: s.m(DcbTag$Reventless.string),
|
|
79
|
+
orderId: s.m(Sury.string)
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
let errorSchema = Sury.literal("Never");
|
|
83
|
+
|
|
84
|
+
function authorizationOf$1(param) {
|
|
85
|
+
return "AllowAuthenticated";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function commandTransition$1(param) {
|
|
89
|
+
return "Unrestricted";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let traits$1 = [];
|
|
93
|
+
|
|
94
|
+
let DemandSlice = {
|
|
95
|
+
name: name$1,
|
|
96
|
+
Id: undefined,
|
|
97
|
+
moduleUrl: moduleUrl$1,
|
|
98
|
+
eventSchema: eventSchema$1,
|
|
99
|
+
consumedEventSchema: consumedEventSchema,
|
|
100
|
+
commandSchema: commandSchema$1,
|
|
101
|
+
errorSchema: errorSchema,
|
|
102
|
+
authorizationOf: authorizationOf$1,
|
|
103
|
+
commandTransition: commandTransition$1,
|
|
104
|
+
traits: traits$1,
|
|
105
|
+
readConsistency: "EscalateOnRetry"
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
let initialState = {
|
|
109
|
+
orders: []
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function evolve(state, event) {
|
|
113
|
+
return {
|
|
114
|
+
orders: state.orders.concat([event.orderId])
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function decide(_state, command) {
|
|
119
|
+
return {
|
|
120
|
+
TAG: "Ok",
|
|
121
|
+
_0: [{
|
|
122
|
+
TAG: "DemandRecorded",
|
|
123
|
+
productId: command.productId,
|
|
124
|
+
orderId: command.orderId
|
|
125
|
+
}]
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let DemandDecision = {
|
|
130
|
+
initialState: initialState,
|
|
131
|
+
moduleUrl: moduleUrl$1,
|
|
132
|
+
evolve: evolve,
|
|
133
|
+
decide: decide
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
let DemandHandler = StateChangeSlice_Callback$ReventlessCore.Make({
|
|
137
|
+
name: name$1,
|
|
138
|
+
moduleUrl: moduleUrl$1,
|
|
139
|
+
consumedEventSchema: consumedEventSchema,
|
|
140
|
+
errorSchema: errorSchema,
|
|
141
|
+
eventSchema: eventSchema$1,
|
|
142
|
+
commandSchema: commandSchema$1,
|
|
143
|
+
authorizationOf: authorizationOf$1,
|
|
144
|
+
commandTransition: commandTransition$1,
|
|
145
|
+
traits: traits$1,
|
|
146
|
+
readConsistency: "EscalateOnRetry"
|
|
147
|
+
})({
|
|
148
|
+
initialState: initialState,
|
|
149
|
+
evolve: evolve,
|
|
150
|
+
decide: decide,
|
|
151
|
+
moduleUrl: moduleUrl$1
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
let partitionTag = {
|
|
155
|
+
TAG: "Simple",
|
|
156
|
+
_0: {
|
|
157
|
+
key: "productId"
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
let moduleUrl$2 = import.meta.url;
|
|
162
|
+
|
|
163
|
+
function mapIncomingEvent(_id, event, _meta, _pluginDef, _queryEngine) {
|
|
164
|
+
return [{
|
|
165
|
+
TAG: "PublishStateChangeSliceCommand",
|
|
166
|
+
_0: {
|
|
167
|
+
TAG: "RecordDemand",
|
|
168
|
+
productId: event.productId,
|
|
169
|
+
orderId: event.orderId
|
|
170
|
+
}
|
|
171
|
+
}];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let handledEvents = [];
|
|
175
|
+
|
|
176
|
+
let issuedCommands = [];
|
|
177
|
+
|
|
178
|
+
let DemandExtension = ExtensionMapping$ReventlessInfra.Make({
|
|
179
|
+
ExtensionPoint: {
|
|
180
|
+
name: name,
|
|
181
|
+
moduleUrl: moduleUrl,
|
|
182
|
+
commandSchema: commandSchema,
|
|
183
|
+
eventSchema: eventSchema,
|
|
184
|
+
directiveSchema: directiveSchema
|
|
185
|
+
},
|
|
186
|
+
Delegate: {
|
|
187
|
+
Id: Id$Reventless.$$String,
|
|
188
|
+
name: name$1,
|
|
189
|
+
eventSchema: eventSchema$1,
|
|
190
|
+
errorSchema: errorSchema,
|
|
191
|
+
commandSchema: commandSchema$1,
|
|
192
|
+
moduleUrl: moduleUrl$1,
|
|
193
|
+
authorizationOf: authorizationOf$1,
|
|
194
|
+
commandTransition: commandTransition$1,
|
|
195
|
+
traits: traits$1
|
|
196
|
+
},
|
|
197
|
+
moduleUrl: moduleUrl$2,
|
|
198
|
+
delegateModuleUrl: moduleUrl$1,
|
|
199
|
+
mapIncomingEvent: mapIncomingEvent,
|
|
200
|
+
mapOutgoingEvent: undefined,
|
|
201
|
+
handledEvents: handledEvents,
|
|
202
|
+
issuedCommands: issuedCommands
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
async function queryEngine_scan(param, param$1, param$2) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function queryEngine_query(param, param$1, param$2, param$3, param$4, param$5, param$6) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let queryEngine = {
|
|
214
|
+
scan: queryEngine_scan,
|
|
215
|
+
query: queryEngine_query
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let pluginDefinition = {
|
|
219
|
+
id: "Catalog@1",
|
|
220
|
+
name: "Catalog",
|
|
221
|
+
version: "1",
|
|
222
|
+
extensionPoints: [],
|
|
223
|
+
extensions: [{
|
|
224
|
+
name: "Catalog",
|
|
225
|
+
extensionPointName: name
|
|
226
|
+
}],
|
|
227
|
+
eventCollector: "eventCollector",
|
|
228
|
+
extensionProtocols: [],
|
|
229
|
+
apiSchemaFragment: undefined,
|
|
230
|
+
structure: undefined,
|
|
231
|
+
kind: "Domain"
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
let published_meta = {
|
|
235
|
+
service: name,
|
|
236
|
+
time: "2024-01-01T00:00:00Z",
|
|
237
|
+
msgId: "item-ordered-msg",
|
|
238
|
+
correlationId: "place-order-cmd",
|
|
239
|
+
causationId: "order-placed-event"
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
let published_event = {
|
|
243
|
+
TAG: "ItemOrdered",
|
|
244
|
+
productId: "product-1",
|
|
245
|
+
orderId: "order-1"
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
let published = {
|
|
249
|
+
id: "order-1",
|
|
250
|
+
meta: published_meta,
|
|
251
|
+
event: published_event
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
function commandOf(event$p) {
|
|
255
|
+
let match = DemandExtension.mapIncomingEvent(event$p, pluginDefinition, queryEngine);
|
|
256
|
+
if (match.length !== 1) {
|
|
257
|
+
return Stdlib_JsError.throwWithMessage("expected one slice command");
|
|
258
|
+
}
|
|
259
|
+
let match$1 = match[0];
|
|
260
|
+
if (match$1.TAG === "AbstractPublishAggregateCommand") {
|
|
261
|
+
return match$1._1;
|
|
262
|
+
} else {
|
|
263
|
+
return Stdlib_JsError.throwWithMessage("expected one slice command");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function eventLogOn(name) {
|
|
268
|
+
let Bus = LocalBus$ReventlessLocal.Make({});
|
|
269
|
+
let Storage = LocalDcbEventLogStorage$ReventlessLocal.Make(Bus);
|
|
270
|
+
let s = Storage.make(name, [], partitionTag, undefined, opts);
|
|
271
|
+
let storage = await TestRunner$ReventlessLocal.resolve(s.operations);
|
|
272
|
+
let publishJson = async (param, param$1, param$2) => {};
|
|
273
|
+
let Log = DcbEventLog_Operations$ReventlessCore.Make({
|
|
274
|
+
name: name,
|
|
275
|
+
serviceName: name,
|
|
276
|
+
storage: storage,
|
|
277
|
+
publishJson: publishJson
|
|
278
|
+
});
|
|
279
|
+
return {
|
|
280
|
+
read: Log.read,
|
|
281
|
+
append: Log.append,
|
|
282
|
+
readStream: Log.readStream,
|
|
283
|
+
appendStream: Log.appendStream
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function runUnderMemory(fn) {
|
|
288
|
+
BackendState$ReventlessLocal.setMemory();
|
|
289
|
+
return await fn();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function runUnderSqlite(fn) {
|
|
293
|
+
let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
|
|
294
|
+
BackendState$ReventlessLocal.setSqlite(db, ":memory:");
|
|
295
|
+
await fn();
|
|
296
|
+
BackendState$ReventlessLocal.setMemory();
|
|
297
|
+
return SqliteDriver$ReventlessLocal.close(db);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function deliver(eventLog, cmd) {
|
|
301
|
+
return Effect.runPromise(DemandHandler.handleCommands(undefined, undefined, partitionTag, eventLog, Stream.fromIterable([{
|
|
302
|
+
command: Message$ReventlessCore.decodeCommand$p(CommandTopic_Helpers$ReventlessCore.encodeCommandJson(cmd), Id$Reventless.$$String.schema, commandSchema$1),
|
|
303
|
+
reference: cmd.meta.msgId
|
|
304
|
+
}])));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function recordedDemands(eventLog) {
|
|
308
|
+
return (await eventLog.read([{
|
|
309
|
+
eventTypes: ["DemandRecorded"]
|
|
310
|
+
}], undefined)).events;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let counter = {
|
|
314
|
+
contents: 0
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
function fresh(prefix) {
|
|
318
|
+
counter.contents = counter.contents + 1 | 0;
|
|
319
|
+
return prefix + `-` + counter.contents.toString();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
globalThis.beforeEach(() => DemandHandler.resetCache());
|
|
323
|
+
|
|
324
|
+
let backends = [
|
|
325
|
+
[
|
|
326
|
+
"in-memory",
|
|
327
|
+
runUnderMemory
|
|
328
|
+
],
|
|
329
|
+
[
|
|
330
|
+
"SQLite",
|
|
331
|
+
runUnderSqlite
|
|
332
|
+
]
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
backends.forEach(param => {
|
|
336
|
+
let runUnder = param[1];
|
|
337
|
+
globalThis.describe(`a redelivered fact is recorded once (` + param[0] + ` backend)`, () => {
|
|
338
|
+
globalThis.test("the same published event delivered twice appends one event", async () => await runUnder(async () => {
|
|
339
|
+
let log = await eventLogOn(fresh("RepeatOnce"));
|
|
340
|
+
let first = await deliver(log, commandOf(published));
|
|
341
|
+
let again = await deliver(log, commandOf(published));
|
|
342
|
+
globalThis.expect(first.length).toBe(1);
|
|
343
|
+
globalThis.expect(again).toEqual(first);
|
|
344
|
+
let events = await recordedDemands(log);
|
|
345
|
+
globalThis.expect(events.length).toBe(1);
|
|
346
|
+
let cmd = commandOf(published);
|
|
347
|
+
globalThis.expect(events[0].meta.causationId).toEqual(cmd.meta.msgId);
|
|
348
|
+
globalThis.expect(cmd.meta.causationId).toEqual(published_meta.msgId);
|
|
349
|
+
}));
|
|
350
|
+
globalThis.test("a different order for the same product still counts", async () => await runUnder(async () => {
|
|
351
|
+
let log = await eventLogOn(fresh("RepeatOther"));
|
|
352
|
+
await deliver(log, commandOf(published));
|
|
353
|
+
let newrecord = {...published_meta};
|
|
354
|
+
let second_meta = (newrecord.msgId = "item-ordered-msg-2", newrecord);
|
|
355
|
+
let second_event = {
|
|
356
|
+
TAG: "ItemOrdered",
|
|
357
|
+
productId: "product-1",
|
|
358
|
+
orderId: "order-2"
|
|
359
|
+
};
|
|
360
|
+
let second = {
|
|
361
|
+
id: "order-2",
|
|
362
|
+
meta: second_meta,
|
|
363
|
+
event: second_event
|
|
364
|
+
};
|
|
365
|
+
await deliver(log, commandOf(second));
|
|
366
|
+
await deliver(log, commandOf(published));
|
|
367
|
+
await deliver(log, commandOf(second));
|
|
368
|
+
globalThis.expect((await recordedDemands(log)).length).toBe(2);
|
|
369
|
+
}));
|
|
370
|
+
globalThis.test("a command with a random id is decided every time", async () => await runUnder(async () => {
|
|
371
|
+
let log = await eventLogOn(fresh("RepeatRandom"));
|
|
372
|
+
let cmd = commandOf(published);
|
|
373
|
+
let newrecord = {...cmd};
|
|
374
|
+
newrecord.meta = Message$ReventlessCore.deriveMeta(cmd.meta, undefined);
|
|
375
|
+
await deliver(log, newrecord);
|
|
376
|
+
await deliver(log, newrecord);
|
|
377
|
+
globalThis.expect((await recordedDemands(log)).length).toBe(2);
|
|
378
|
+
}));
|
|
379
|
+
globalThis.test("two concurrent copies append one event", async () => await runUnder(async () => {
|
|
380
|
+
let log = await eventLogOn(fresh("RepeatConcurrent"));
|
|
381
|
+
let cmd = commandOf(published);
|
|
382
|
+
let match = await Promise.all([
|
|
383
|
+
deliver(log, cmd),
|
|
384
|
+
deliver(log, cmd)
|
|
385
|
+
]);
|
|
386
|
+
let b = match[1];
|
|
387
|
+
let a = match[0];
|
|
388
|
+
globalThis.expect(a.length).toBe(1);
|
|
389
|
+
globalThis.expect(b.length).toBe(1);
|
|
390
|
+
globalThis.expect(Stdlib_Result.isOk(a[0]) && Stdlib_Result.isOk(b[0])).toBe(true);
|
|
391
|
+
globalThis.expect((await recordedDemands(log)).length).toBe(1);
|
|
392
|
+
}));
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
let Message;
|
|
397
|
+
|
|
398
|
+
export {
|
|
399
|
+
opts,
|
|
400
|
+
Message,
|
|
401
|
+
Port,
|
|
402
|
+
DemandSlice,
|
|
403
|
+
DemandDecision,
|
|
404
|
+
DemandHandler,
|
|
405
|
+
partitionTag,
|
|
406
|
+
DemandExtension,
|
|
407
|
+
queryEngine,
|
|
408
|
+
pluginDefinition,
|
|
409
|
+
published,
|
|
410
|
+
commandOf,
|
|
411
|
+
eventLogOn,
|
|
412
|
+
runUnderMemory,
|
|
413
|
+
runUnderSqlite,
|
|
414
|
+
deliver,
|
|
415
|
+
recordedDemands,
|
|
416
|
+
counter,
|
|
417
|
+
fresh,
|
|
418
|
+
backends,
|
|
419
|
+
}
|
|
420
|
+
/* Not a pure module */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// A ReadModel whose `Mapping.sourceName` doesn't match any key in `allEventTopics`
|
|
2
|
+
// fails plugin assembly (here: ReadModel construction) with a clear error, instead
|
|
3
|
+
// of silently producing zero events. A dotted name is an extension point's, and
|
|
4
|
+
// the error then also says how another plugin's facts do reach a read model.
|
|
4
5
|
|
|
5
6
|
open JestGlobals
|
|
6
7
|
open Reventless
|
|
@@ -68,22 +69,77 @@ let allEventTopics: ReventlessInfra.EventTopic.allOutputs = Dict.fromArray([
|
|
|
68
69
|
("ProductCatalogDcbEventLog", {ReventlessInfra.EventTopic.resources: [topicResource]}),
|
|
69
70
|
])
|
|
70
71
|
|
|
72
|
+
// A Source named like an extension point ("Ordering.Orders"): a read model
|
|
73
|
+
// cannot subscribe to one, so the message must say where to go instead.
|
|
74
|
+
module ExtensionPointSource = {
|
|
75
|
+
module Id = Reventless.Id.String
|
|
76
|
+
let name = "Ordering.Orders"
|
|
77
|
+
|
|
78
|
+
@schema
|
|
79
|
+
type event = ItemOrdered({productId: string, orderId: string})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module EpRmSpec = {
|
|
83
|
+
module Id = Reventless.Id.String
|
|
84
|
+
let name = "EpCheckReadModel"
|
|
85
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
86
|
+
|
|
87
|
+
@schema
|
|
88
|
+
type state = {productId: string, orderCount: int}
|
|
89
|
+
|
|
90
|
+
let config = ReadModel.config()
|
|
91
|
+
let subIdConfig = None
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module EpMapping = Mapping.Make(
|
|
95
|
+
ExtensionPointSource,
|
|
96
|
+
EpRmSpec,
|
|
97
|
+
{
|
|
98
|
+
let project = (msg: Message.event'<ExtensionPointSource.Id.t, ExtensionPointSource.event>) =>
|
|
99
|
+
switch msg.event {
|
|
100
|
+
| ItemOrdered({productId}) =>
|
|
101
|
+
Set(EpRmSpec.Id.makeFromString(productId), ({productId, orderCount: 1}: EpRmSpec.state))
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
module EpMappings: Mappings with module Target := EpRmSpec = {
|
|
107
|
+
module Mappings = Mappings.Make(EpRmSpec)
|
|
108
|
+
module type Mapping = Mappings.Mapping
|
|
109
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
110
|
+
let mappings: array<module(Mapping)> = [module(EpMapping)]
|
|
111
|
+
}
|
|
112
|
+
|
|
71
113
|
module ReadModelMaker = ReadModel_Builder.Make(Bus)
|
|
72
114
|
module RmMaker = ReadModelMaker.Make(RmSpec, TypoMappings)
|
|
115
|
+
module EpRmMaker = ReadModelMaker.Make(EpRmSpec, EpMappings)
|
|
116
|
+
|
|
117
|
+
let messageOf = make => {
|
|
118
|
+
let msg = ref(None)
|
|
119
|
+
try {
|
|
120
|
+
let _ = make()
|
|
121
|
+
} catch {
|
|
122
|
+
| JsExn(err) => msg := Some(err->JsExn.message->Option.getOr(""))
|
|
123
|
+
}
|
|
124
|
+
msg.contents
|
|
125
|
+
}
|
|
73
126
|
|
|
74
127
|
describe("ReadModel source-name fail-fast:", () => {
|
|
75
128
|
testSync("constructing a ReadModel whose Mapping.sourceName is missing throws", () => {
|
|
76
|
-
let
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
expect(msg
|
|
129
|
+
let msg =
|
|
130
|
+
messageOf(() => RmMaker.make(~api=(), ~apiRole=(), ~allEventTopics))->Option.getOrThrow
|
|
131
|
+
expect(msg->String.includes("MissingDcb"))->toBe(true)
|
|
132
|
+
expect(msg->String.includes("ProductCatalogDcbEventLog"))->toBe(true)
|
|
133
|
+
// A typo keeps the plain message: no extension point is being named.
|
|
134
|
+
expect(msg->String.includes("extension point"))->toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
testSync("a dotted sourceName names the Extension → slice route instead", () => {
|
|
138
|
+
let msg =
|
|
139
|
+
messageOf(() => EpRmMaker.make(~api=(), ~apiRole=(), ~allEventTopics))->Option.getOrThrow
|
|
140
|
+
expect(msg->String.includes("Ordering.Orders"))->toBe(true)
|
|
141
|
+
expect(msg->String.includes("looks like an extension point name"))->toBe(true)
|
|
142
|
+
expect(msg->String.includes("Declare an Extension"))->toBe(true)
|
|
143
|
+
expect(msg->String.includes("Targeting a StateChangeSlice instead of an Aggregate"))->toBe(true)
|
|
88
144
|
})
|
|
89
145
|
})
|
|
@@ -104,6 +104,84 @@ let allEventTopics = Object.fromEntries([[
|
|
|
104
104
|
}
|
|
105
105
|
]]);
|
|
106
106
|
|
|
107
|
+
let name$2 = "Ordering.Orders";
|
|
108
|
+
|
|
109
|
+
let eventSchema$1 = Sury.$schema(s => ({
|
|
110
|
+
TAG: "ItemOrdered",
|
|
111
|
+
productId: s.m(Sury.string),
|
|
112
|
+
orderId: s.m(Sury.string)
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
let ExtensionPointSource = {
|
|
116
|
+
Id: undefined,
|
|
117
|
+
name: name$2,
|
|
118
|
+
eventSchema: eventSchema$1
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
let name$3 = "EpCheckReadModel";
|
|
122
|
+
|
|
123
|
+
let moduleUrl$2 = import.meta.url;
|
|
124
|
+
|
|
125
|
+
let stateSchema$1 = Sury.$schema(s => ({
|
|
126
|
+
productId: s.m(Sury.string),
|
|
127
|
+
orderCount: s.m(Sury.int32)
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
let config$1 = ReadModel$Reventless.config(undefined, undefined, undefined);
|
|
131
|
+
|
|
132
|
+
let EpRmSpec = {
|
|
133
|
+
Id: undefined,
|
|
134
|
+
name: name$3,
|
|
135
|
+
moduleUrl: moduleUrl$2,
|
|
136
|
+
stateSchema: stateSchema$1,
|
|
137
|
+
config: config$1,
|
|
138
|
+
subIdConfig: undefined,
|
|
139
|
+
authorization: "AllowAuthenticated",
|
|
140
|
+
visibility: "Public"
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function project$1(msg) {
|
|
144
|
+
let match = msg.event;
|
|
145
|
+
let productId = match.productId;
|
|
146
|
+
return {
|
|
147
|
+
TAG: "Set",
|
|
148
|
+
_0: Id$Reventless.$$String.makeFromString(productId),
|
|
149
|
+
_1: {
|
|
150
|
+
productId: productId,
|
|
151
|
+
orderCount: 1
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let EpMapping = Projection$Reventless.Mapping.Make({
|
|
157
|
+
Id: Id$Reventless.$$String,
|
|
158
|
+
name: name$2,
|
|
159
|
+
eventSchema: eventSchema$1
|
|
160
|
+
})({
|
|
161
|
+
Id: Id$Reventless.$$String,
|
|
162
|
+
name: name$3,
|
|
163
|
+
stateSchema: stateSchema$1,
|
|
164
|
+
subIdConfig: undefined
|
|
165
|
+
})({
|
|
166
|
+
project: project$1
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
Projection$Reventless.Mappings.Make({
|
|
170
|
+
Id: Id$Reventless.$$String,
|
|
171
|
+
name: name$3,
|
|
172
|
+
stateSchema: stateSchema$1,
|
|
173
|
+
subIdConfig: undefined
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
let moduleUrl$3 = import.meta.url;
|
|
177
|
+
|
|
178
|
+
let mappings$1 = [EpMapping];
|
|
179
|
+
|
|
180
|
+
let EpMappings = {
|
|
181
|
+
moduleUrl: moduleUrl$3,
|
|
182
|
+
mappings: mappings$1
|
|
183
|
+
};
|
|
184
|
+
|
|
107
185
|
let ReadModelMaker = ReadModel_Builder$ReventlessLocal.Make(Bus);
|
|
108
186
|
|
|
109
187
|
let RmMaker = ReadModelMaker.Make({
|
|
@@ -117,24 +195,45 @@ let RmMaker = ReadModelMaker.Make({
|
|
|
117
195
|
visibility: "Public"
|
|
118
196
|
})(TypoMappings);
|
|
119
197
|
|
|
198
|
+
let EpRmMaker = ReadModelMaker.Make({
|
|
199
|
+
Id: Id$Reventless.$$String,
|
|
200
|
+
name: name$3,
|
|
201
|
+
moduleUrl: moduleUrl$2,
|
|
202
|
+
stateSchema: stateSchema$1,
|
|
203
|
+
config: config$1,
|
|
204
|
+
subIdConfig: undefined,
|
|
205
|
+
authorization: "AllowAuthenticated",
|
|
206
|
+
visibility: "Public"
|
|
207
|
+
})(EpMappings);
|
|
208
|
+
|
|
209
|
+
function messageOf(make) {
|
|
210
|
+
let msg;
|
|
211
|
+
try {
|
|
212
|
+
make();
|
|
213
|
+
} catch (raw_err) {
|
|
214
|
+
let err = Primitive_exceptions.internalToException(raw_err);
|
|
215
|
+
if (err.RE_EXN_ID === "JsExn") {
|
|
216
|
+
msg = Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "");
|
|
217
|
+
} else {
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return msg;
|
|
222
|
+
}
|
|
223
|
+
|
|
120
224
|
globalThis.describe("ReadModel source-name fail-fast:", () => {
|
|
121
225
|
globalThis.test("constructing a ReadModel whose Mapping.sourceName is missing throws", () => {
|
|
122
|
-
let
|
|
123
|
-
let msg = "";
|
|
124
|
-
try {
|
|
125
|
-
RmMaker.make(undefined, undefined, allEventTopics, undefined, undefined);
|
|
126
|
-
} catch (raw_err) {
|
|
127
|
-
let err = Primitive_exceptions.internalToException(raw_err);
|
|
128
|
-
if (err.RE_EXN_ID === "JsExn") {
|
|
129
|
-
threw = true;
|
|
130
|
-
msg = Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "");
|
|
131
|
-
} else {
|
|
132
|
-
throw err;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
globalThis.expect(threw).toBe(true);
|
|
226
|
+
let msg = Stdlib_Option.getOrThrow(messageOf(() => RmMaker.make(undefined, undefined, allEventTopics, undefined, undefined)), undefined);
|
|
136
227
|
globalThis.expect(msg.includes("MissingDcb")).toBe(true);
|
|
137
228
|
globalThis.expect(msg.includes("ProductCatalogDcbEventLog")).toBe(true);
|
|
229
|
+
globalThis.expect(msg.includes("extension point")).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
globalThis.test("a dotted sourceName names the Extension → slice route instead", () => {
|
|
232
|
+
let msg = Stdlib_Option.getOrThrow(messageOf(() => EpRmMaker.make(undefined, undefined, allEventTopics, undefined, undefined)), undefined);
|
|
233
|
+
globalThis.expect(msg.includes("Ordering.Orders")).toBe(true);
|
|
234
|
+
globalThis.expect(msg.includes("looks like an extension point name")).toBe(true);
|
|
235
|
+
globalThis.expect(msg.includes("Declare an Extension")).toBe(true);
|
|
236
|
+
globalThis.expect(msg.includes("Targeting a StateChangeSlice instead of an Aggregate")).toBe(true);
|
|
138
237
|
});
|
|
139
238
|
});
|
|
140
239
|
|
|
@@ -146,7 +245,13 @@ export {
|
|
|
146
245
|
TypoMappings,
|
|
147
246
|
topicResource,
|
|
148
247
|
allEventTopics,
|
|
248
|
+
ExtensionPointSource,
|
|
249
|
+
EpRmSpec,
|
|
250
|
+
EpMapping,
|
|
251
|
+
EpMappings,
|
|
149
252
|
ReadModelMaker,
|
|
150
253
|
RmMaker,
|
|
254
|
+
EpRmMaker,
|
|
255
|
+
messageOf,
|
|
151
256
|
}
|
|
152
257
|
/* Bus Not a pure module */
|
|
@@ -7,9 +7,7 @@ let pluginDefinition: Reventless.Plugin.pluginDefinition = {
|
|
|
7
7
|
name: "name",
|
|
8
8
|
version: "1",
|
|
9
9
|
extensionPoints: [],
|
|
10
|
-
extensions: [
|
|
11
|
-
{name: "Platform.Plugin.Test", extensionPointName: "Platform.Plugin", dcbSources: []},
|
|
12
|
-
],
|
|
10
|
+
extensions: [{name: "Platform.Plugin.Test", extensionPointName: "Platform.Plugin"}],
|
|
13
11
|
eventCollector: "eventCollector",
|
|
14
12
|
extensionProtocols: [],
|
|
15
13
|
apiSchemaFragment: None,
|
|
@@ -10,8 +10,7 @@ let pluginDefinition = {
|
|
|
10
10
|
extensionPoints: [],
|
|
11
11
|
extensions: [{
|
|
12
12
|
name: "Platform.Plugin.Test",
|
|
13
|
-
extensionPointName: "Platform.Plugin"
|
|
14
|
-
dcbSources: []
|
|
13
|
+
extensionPointName: "Platform.Plugin"
|
|
15
14
|
}],
|
|
16
15
|
eventCollector: "eventCollector",
|
|
17
16
|
extensionProtocols: [],
|