What you are looking for is a stateful partitioned continuous projection.
It needs to do the following:
- Partition the output state by the user id using the stream name
- Initialize the partition state by setting the login count to zero
- Optionally, initialize the shared state with the number of unique users and the total number of logins by setting both to zero
- Increase the login count by one for each new login event
- Optionally, increase the total login count in the shared state for each login event
- When the new partition is created (you got a new user), you can increase the number of users by one
- Output both states (notice the
biState
option) to separate streams. The shared state has a static stream name, and the partition state (per user) has a format string where the only argument is the partition id (user id in your case)
Here's the projection code that works:
options({
$includeLinks: true,
biState: true
})
fromCategory('user')
.partitionBy(function (e) {
return e.streamId.split("-")[1];
})
.when({
$init: function() {
return {
logins: 0,
}
},
$initShared: function() {
return {
numberOfUsers: 0,
totalLogins: 0
}
},
$created: function (s, e) {
s[1].numberOfUsers++;
},
"login": function(s, e) {
s[0].logins++;
s[1].totalLogins++;
return s;
},
})
.outputState()
.outputTo("totalUsers", "logins-{0}");
When using the biState
, you get an array in event handlers and in $created
. The first element (index zero) is the partition state, and the second element (index one) is the shared state.
I tested it with two streams: user-123
and user-124
, and I emitted a couple of login
events to both streams.
When I look at the projection, I see the shared state, and the field to enter the partition id:

When I enter the partition id, I can see the partition state:

You then can find totalLogins
and login-XXX
(by user) streams with the latest state. The totalLogins
stream would actually contain links to individual login-XXX
streams:

Here's how the event in login-XXX
looks like:

When the projection emits a new state to the state streams, it also truncates the state stream. So, when you read the stream, I can suggest that you read backwards with the count one. In production, you'd need to scavenge the database regularly, otherwise, those truncated events will occupy a lot of space.
Previous answer
as the question was modified, the answer seems out of context
When I read the docs following the link in your question, I see this:

It says that the argument for the forStreamMatching
function is a function that should return true
of false
given the stream name, like stream => stream.startsWith("person.")
. I am not sure where the docs say that it would work with a string prefix.
A warning: the ES6 syntax is only supported in ESDB >= 21.10