@ossy/event-store 1.8.2 → 3.0.1
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/README.md +91 -82
- package/package.json +11 -4
- package/src/aggregate-rebuild.js +113 -51
- package/src/aggregate-rebuild.spec.js +54 -0
- package/src/aggregate-rebuild.task.js +13 -7
- package/src/aggregate.js +33 -130
- package/src/ensure-indexes.js +41 -0
- package/src/ensure-indexes.spec.js +9 -0
- package/src/event-store.js +70 -78
- package/src/index.js +5 -0
- package/src/projection-queries.js +17 -0
- package/src/projection-rebuild.js +136 -0
- package/src/push-invalidation.js +130 -0
- package/src/push-invalidation.spec.js +64 -0
- package/src/schema-stream.js +104 -0
package/src/aggregate.js
CHANGED
|
@@ -1,157 +1,60 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
|
-
import {
|
|
3
|
-
import { EventStore } from './event-store.js'
|
|
2
|
+
import { SchemaStream } from './schema-stream.js'
|
|
4
3
|
import { createLogger } from '@ossy/observability'
|
|
5
4
|
|
|
6
5
|
const log = createLogger('event-store')
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
10
|
-
* Use this class if you want to create a new stream, add events to an existing stream or view streams.
|
|
11
|
-
* @class
|
|
8
|
+
* ADR 0008 entity stream facade — all aggregates require `SchemaId`.
|
|
12
9
|
*/
|
|
13
10
|
export class Aggregate {
|
|
14
11
|
|
|
15
|
-
static get Collection() {
|
|
16
|
-
return
|
|
12
|
+
static get Collection () {
|
|
13
|
+
return SchemaStream.Collection
|
|
17
14
|
}
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* @return {Aggregate} - Instance of Aggregate
|
|
25
|
-
*
|
|
26
|
-
* @example
|
|
27
|
-
* Aggregate.Of(User, userId)
|
|
28
|
-
* Aggregate.Of(Workspace, workspaceId)
|
|
29
|
-
*/
|
|
30
|
-
static Of(AggregateRoot, identifier) {
|
|
31
|
-
|
|
32
|
-
log.info(`[Aggregate][Of()][${AggregateRoot?.AggregateType}] Creating aggregate root`)
|
|
33
|
-
|
|
34
|
-
if (typeof identifier === 'string') {
|
|
35
|
-
|
|
36
|
-
return Aggregate.Find(identifier)
|
|
37
|
-
.then(aggregate => {
|
|
38
|
-
return EventStore.GetEventStream({ aggregateId: identifier, fromVersion: aggregate?.version })
|
|
39
|
-
.then(events => new Aggregate(AggregateRoot, identifier, events, aggregate))
|
|
40
|
-
})
|
|
16
|
+
static Of (AggregateRoot, identifier) {
|
|
17
|
+
if (!AggregateRoot?.SchemaId) {
|
|
18
|
+
return Promise.reject(new Error(
|
|
19
|
+
`[Aggregate] ${AggregateRoot?.AggregateType ?? 'Unknown'} missing SchemaId — use an ADR 0008 entity aggregate`,
|
|
20
|
+
))
|
|
41
21
|
}
|
|
42
22
|
|
|
43
|
-
if (typeof identifier === 'object') {
|
|
44
|
-
log.info(
|
|
45
|
-
|
|
23
|
+
if (typeof identifier === 'object' && identifier !== null) {
|
|
24
|
+
log.info(`[Aggregate][Of] Creating ${AggregateRoot.SchemaId}`)
|
|
25
|
+
const resourceId = identifier.resourceId ?? identifier.aggregateId ?? nanoid()
|
|
26
|
+
const event = {
|
|
27
|
+
...identifier,
|
|
28
|
+
resourceId,
|
|
29
|
+
type: identifier.type ?? AggregateRoot.SchemaId,
|
|
30
|
+
}
|
|
31
|
+
return Promise.resolve(new SchemaStream(AggregateRoot, resourceId, [], null))
|
|
32
|
+
.then(SchemaStream.Add(event))
|
|
46
33
|
}
|
|
47
34
|
|
|
48
|
-
log.
|
|
49
|
-
return
|
|
35
|
+
log.info(`[Aggregate][Of] ${AggregateRoot.SchemaId}`)
|
|
36
|
+
return SchemaStream.Of(AggregateRoot, identifier)
|
|
50
37
|
}
|
|
51
38
|
|
|
52
|
-
static Find(identifier) {
|
|
53
|
-
|
|
54
|
-
return withMongoReconnect(() => Aggregate.Collection.findOne({ id: identifier }))
|
|
55
|
-
.then(aggregate => {
|
|
56
|
-
if (!aggregate) {
|
|
57
|
-
log.debug(`[Aggregate][Find()] No aggregate found for ${identifier}`)
|
|
58
|
-
}
|
|
59
|
-
return aggregate
|
|
60
|
-
})
|
|
39
|
+
static Find (identifier) {
|
|
40
|
+
return SchemaStream.Find(identifier)
|
|
61
41
|
}
|
|
62
42
|
|
|
63
|
-
static Add(event) {
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
return EventStore.AppendEvent({
|
|
67
|
-
...event,
|
|
68
|
-
id: nanoid(),
|
|
69
|
-
created: Date.now(),
|
|
70
|
-
createdBy: event.createdBy,
|
|
71
|
-
aggregateType: aggregate.type,
|
|
72
|
-
aggregateId: aggregate.id,
|
|
73
|
-
aggregateVersion: aggregate.version + 1,
|
|
74
|
-
})
|
|
75
|
-
.then(savedEvent => {
|
|
76
|
-
aggregate.version = savedEvent.aggregateVersion,
|
|
77
|
-
aggregate.events = [...aggregate.events, savedEvent]
|
|
78
|
-
return aggregate
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
|
|
43
|
+
static Add (event) {
|
|
44
|
+
return stream => SchemaStream.Add(event)(stream)
|
|
82
45
|
}
|
|
83
46
|
|
|
84
|
-
static Save() {
|
|
85
|
-
return
|
|
86
|
-
log.debug(`[Aggregate][Save] Saving state for ${aggregate.type} ${aggregate.id}`)
|
|
87
|
-
|
|
88
|
-
if (!aggregate.events || aggregate.events.length === 0) {
|
|
89
|
-
log.debug(`[Aggregate][Save] No events to save for ${aggregate.type} ${aggregate.id}`)
|
|
90
|
-
return Promise.resolve()
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const latestEventVersion = [...aggregate.events].sort((a, b) => a.aggregateVersion - b.aggregateVersion).pop()?.aggregateVersion
|
|
94
|
-
|
|
95
|
-
if (latestEventVersion < aggregate.version) {
|
|
96
|
-
log.debug(`[Aggregate][Save] No new events to save for ${aggregate.type} ${aggregate.id}`)
|
|
97
|
-
return Promise.resolve()
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const state = aggregate.View(aggregate.events, aggregate.state)
|
|
101
|
-
|
|
102
|
-
return withMongoReconnect(() =>
|
|
103
|
-
Aggregate.Collection.updateOne({ id: aggregate.id }, {
|
|
104
|
-
$set: {
|
|
105
|
-
id: aggregate.id,
|
|
106
|
-
version: latestEventVersion,
|
|
107
|
-
type: aggregate.type,
|
|
108
|
-
state: state
|
|
109
|
-
}
|
|
110
|
-
}, { upsert: true })
|
|
111
|
-
)
|
|
112
|
-
.catch(error => {
|
|
113
|
-
log.error(`[Aggregate][Save] Failed to save state for ${aggregate.type} ${aggregate.id}`, undefined, error)
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
static Validate(validator) {
|
|
119
|
-
return aggregate => Promise.resolve()
|
|
120
|
-
.then(() => validator(aggregate.events, aggregate.state))
|
|
121
|
-
.then(() => aggregate)
|
|
47
|
+
static Save () {
|
|
48
|
+
return stream => SchemaStream.Save()(stream)
|
|
122
49
|
}
|
|
123
50
|
|
|
124
|
-
static
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (typeof view === 'function') {
|
|
129
|
-
return view(aggregate.events, aggregate.state)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return aggregate.View(aggregate.events, aggregate.state)
|
|
133
|
-
}
|
|
134
|
-
|
|
51
|
+
static Validate (validator) {
|
|
52
|
+
return stream => Promise.resolve()
|
|
53
|
+
.then(() => validator(stream.events, stream.state))
|
|
54
|
+
.then(() => stream)
|
|
135
55
|
}
|
|
136
56
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
* @param {string} id - Id of aggregate - aggregateId
|
|
141
|
-
* @param {string} root - root aggregate (class that aggregates the events)
|
|
142
|
-
* @param {Event[]} events - The events that make up the stream
|
|
143
|
-
* @param {Object} aggregate - The object representing the aggregate of all the events
|
|
144
|
-
*/
|
|
145
|
-
constructor(AggregateRoot, id, events, aggregate) {
|
|
146
|
-
log.debug(`[Aggregate][Constructor] Assembling ${AggregateRoot.AggregateType} ${id}`)
|
|
147
|
-
|
|
148
|
-
this.id = id;
|
|
149
|
-
this.version = aggregate?.version || 0
|
|
150
|
-
this.type = AggregateRoot.AggregateType
|
|
151
|
-
this.events = events || []
|
|
152
|
-
this.state = aggregate?.state
|
|
153
|
-
|
|
154
|
-
this.View = AggregateRoot.View
|
|
57
|
+
static View (view) {
|
|
58
|
+
return stream => SchemaStream.View(view)(stream)
|
|
155
59
|
}
|
|
156
|
-
|
|
157
60
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
import { Mongo, withMongoReconnect } from './mongodb.js'
|
|
3
|
+
|
|
4
|
+
const log = createLogger('event-store')
|
|
5
|
+
|
|
6
|
+
let ensured = false
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensure MongoDB indexes required for aggregate/event-stream reads.
|
|
10
|
+
* Without `{ resourceId, version }` on `eventstore`, every `getResourceStream`
|
|
11
|
+
* call scans the full collection — sign-up and changestream rebuilds deadlock under load.
|
|
12
|
+
*/
|
|
13
|
+
export async function ensureEventStoreIndexes () {
|
|
14
|
+
if (ensured) return
|
|
15
|
+
|
|
16
|
+
await withMongoReconnect(async () => {
|
|
17
|
+
const eventstore = Mongo.db.collection('eventstore')
|
|
18
|
+
await eventstore.createIndex(
|
|
19
|
+
{ resourceId: 1, version: 1 },
|
|
20
|
+
{ name: 'resourceId_version', background: true },
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
const aggregates = Mongo.db.collection('aggregates')
|
|
24
|
+
await aggregates.createIndex(
|
|
25
|
+
{ id: 1 },
|
|
26
|
+
{ name: 'aggregate_id', unique: true, background: true },
|
|
27
|
+
)
|
|
28
|
+
await aggregates.createIndex(
|
|
29
|
+
{ type: 1, 'state.email': 1 },
|
|
30
|
+
{ name: 'type_state_email', background: true, sparse: true },
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
ensured = true
|
|
35
|
+
log.info('[ensure-indexes] eventstore and aggregates indexes ready')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @internal test helper */
|
|
39
|
+
export function resetEnsureIndexesForTests () {
|
|
40
|
+
ensured = false
|
|
41
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { ensureEventStoreIndexes, resetEnsureIndexesForTests } from './ensure-indexes.js'
|
|
3
|
+
|
|
4
|
+
describe('ensureEventStoreIndexes', () => {
|
|
5
|
+
it('is exported and idempotent flag resets in tests', () => {
|
|
6
|
+
expect(typeof ensureEventStoreIndexes).toBe('function')
|
|
7
|
+
resetEnsureIndexesForTests()
|
|
8
|
+
})
|
|
9
|
+
})
|
package/src/event-store.js
CHANGED
|
@@ -4,108 +4,101 @@ import { createLogger } from '@ossy/observability'
|
|
|
4
4
|
const log = createLogger('event-store')
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* ADR 0008 resource event append.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} event
|
|
10
|
+
* @returns {Promise<object>}
|
|
11
|
+
*/
|
|
12
|
+
export function appendResourceEvent (event) {
|
|
13
|
+
log.info('[EventStore] Appending resource event')
|
|
14
|
+
return withMongoReconnect(() => Mongo.db.collection('eventstore').insertOne(event))
|
|
15
|
+
.then(insertResult => (insertResult.acknowledged ? event : Promise.reject()))
|
|
16
|
+
.catch(error => {
|
|
17
|
+
log.error('[EventStore] Could not append resource event', undefined, error)
|
|
18
|
+
return Promise.reject(error)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ resourceId: string, fromVersion?: number }} opts
|
|
24
|
+
* @returns {Promise<object[]>}
|
|
25
|
+
*/
|
|
26
|
+
export function getResourceStream ({ resourceId, fromVersion = 0 }) {
|
|
27
|
+
const query = {
|
|
28
|
+
resourceId,
|
|
29
|
+
version: { $gt: fromVersion },
|
|
30
|
+
}
|
|
31
|
+
return withMongoReconnect(() =>
|
|
32
|
+
EventStore.Collection.find(query, { sort: { version: 1 } }).toArray(),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @returns {Promise<{ resourceId: string, type: string }[]>}
|
|
38
|
+
*/
|
|
39
|
+
export function getResourceStreams () {
|
|
40
|
+
return EventStore.Aggregate([
|
|
41
|
+
{ $match: { resourceId: { $exists: true, $ne: null } } },
|
|
42
|
+
{
|
|
43
|
+
$group: {
|
|
44
|
+
_id: { resourceId: '$resourceId', type: '$type' },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
$project: {
|
|
49
|
+
_id: 0,
|
|
50
|
+
resourceId: '$_id.resourceId',
|
|
51
|
+
type: '$_id.type',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
8
58
|
* @class
|
|
9
59
|
*/
|
|
10
60
|
export class EventStore {
|
|
11
61
|
|
|
12
|
-
static get Collection() {
|
|
62
|
+
static get Collection () {
|
|
13
63
|
return Mongo.db.collection('eventstore')
|
|
14
64
|
}
|
|
15
65
|
|
|
16
|
-
static
|
|
17
|
-
|
|
18
|
-
|
|
66
|
+
static AppendResourceEvent (event) {
|
|
67
|
+
return appendResourceEvent(event)
|
|
68
|
+
}
|
|
19
69
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return insertResult.acknowledged
|
|
23
|
-
? Promise.resolve(event)
|
|
24
|
-
: Promise.reject()
|
|
25
|
-
})
|
|
26
|
-
.catch(error => {
|
|
27
|
-
log.error('[EventStore] Could not append event', undefined, error)
|
|
28
|
-
return Promise.reject()
|
|
29
|
-
})
|
|
70
|
+
static GetResourceStream (opts) {
|
|
71
|
+
return getResourceStream(opts)
|
|
30
72
|
}
|
|
31
73
|
|
|
32
|
-
static
|
|
33
|
-
|
|
34
|
-
|
|
74
|
+
static GetResourceStreams () {
|
|
75
|
+
return getResourceStreams()
|
|
76
|
+
}
|
|
35
77
|
|
|
78
|
+
static FindEvent (query) {
|
|
79
|
+
log.info('[EventStore] Searching for event')
|
|
36
80
|
return withMongoReconnect(() => EventStore.Collection.findOne(query))
|
|
37
|
-
.then(event =>
|
|
81
|
+
.then(event => (event ? event : Promise.reject()))
|
|
38
82
|
.catch(error => {
|
|
39
83
|
log.error('[EventStore] No event found', undefined, error)
|
|
40
|
-
return Promise.reject()
|
|
84
|
+
return Promise.reject(error)
|
|
41
85
|
})
|
|
42
86
|
}
|
|
43
87
|
|
|
44
|
-
static FindEvents(query) {
|
|
88
|
+
static FindEvents (query) {
|
|
45
89
|
log.info('[EventStore] Searching for events')
|
|
46
|
-
log.debug('[EventStore] Query', { query })
|
|
47
|
-
|
|
48
90
|
return withMongoReconnect(() =>
|
|
49
|
-
EventStore.Collection.find(query, { sort: {
|
|
91
|
+
EventStore.Collection.find(query, { sort: { version: 1, created: 1 } }).toArray(),
|
|
50
92
|
)
|
|
51
|
-
.then(events =>
|
|
93
|
+
.then(events => (events?.length ? events : Promise.reject()))
|
|
52
94
|
.catch(error => {
|
|
53
95
|
log.error('[EventStore] No events found', undefined, error)
|
|
54
|
-
return Promise.reject()
|
|
55
|
-
})
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
static GetEventStream({
|
|
59
|
-
aggregateId,
|
|
60
|
-
fromVersion = 0
|
|
61
|
-
}) {
|
|
62
|
-
log.debug(`[EventStore][GetEventStream()] Fetching stream ${aggregateId} from version ${fromVersion}`)
|
|
63
|
-
|
|
64
|
-
const query = {
|
|
65
|
-
aggregateId: aggregateId,
|
|
66
|
-
aggregateVersion: { '$gt': fromVersion }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const options = {
|
|
70
|
-
sort: { aggregateVersion: 1 }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return withMongoReconnect(() => EventStore.Collection.find(query, options).toArray())
|
|
74
|
-
.then(events => {
|
|
75
|
-
log.debug(`[EventStore][GetEventStream()] Found ${events.length} events for ${aggregateId}`)
|
|
76
|
-
return events
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
static GetEventStreams() {
|
|
81
|
-
log.info('[EventStore][GetEventStreams()] Fetching event streams')
|
|
82
|
-
|
|
83
|
-
return EventStore.Aggregate([
|
|
84
|
-
{
|
|
85
|
-
'$group': {
|
|
86
|
-
'_id': {
|
|
87
|
-
'aggregateId': '$aggregateId',
|
|
88
|
-
'aggregateType': '$aggregateType'
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
'$project': {
|
|
94
|
-
'_id': 0,
|
|
95
|
-
'aggregateId': '$_id.aggregateId',
|
|
96
|
-
'aggregateType': '$_id.aggregateType'
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
])
|
|
100
|
-
.then(result => {
|
|
101
|
-
log.debug(`[EventStore][GetEventStreams()] found ${result.length} streams`)
|
|
102
|
-
return result
|
|
96
|
+
return Promise.reject(error)
|
|
103
97
|
})
|
|
104
98
|
}
|
|
105
99
|
|
|
106
|
-
static Aggregate(pipeline) {
|
|
100
|
+
static Aggregate (pipeline) {
|
|
107
101
|
log.info('[EventStore] Running aggregation pipeline')
|
|
108
|
-
|
|
109
102
|
return withMongoReconnect(() => EventStore.Collection.aggregate(pipeline).toArray())
|
|
110
103
|
.then(events => (Array.isArray(events) ? events : []))
|
|
111
104
|
.catch(error => {
|
|
@@ -114,8 +107,7 @@ export class EventStore {
|
|
|
114
107
|
})
|
|
115
108
|
}
|
|
116
109
|
|
|
117
|
-
static CloseDbConnection() {
|
|
110
|
+
static CloseDbConnection () {
|
|
118
111
|
return Mongo.closeConnection()
|
|
119
112
|
}
|
|
120
|
-
|
|
121
113
|
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export * from './mongodb.js'
|
|
2
2
|
export * from './event-store.js'
|
|
3
3
|
export * from './aggregate.js'
|
|
4
|
+
export * from './schema-stream.js'
|
|
4
5
|
export * from './aggregate-rebuild.js'
|
|
6
|
+
export * from './projection-rebuild.js'
|
|
7
|
+
export { getProjection } from './projection-queries.js'
|
|
8
|
+
export { PushInvalidation, buildInvalidationKeys, buildPushMessage } from './push-invalidation.js'
|
|
9
|
+
export { ensureEventStoreIndexes, resetEnsureIndexesForTests } from './ensure-indexes.js'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { withMongoReconnect } from './mongodb.js'
|
|
2
|
+
import { ProjectionRebuild } from './projection-rebuild.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load a projection snapshot by scope id and projection type id.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} scopeId e.g. workspaceId
|
|
8
|
+
* @param {string} projectionId e.g. `@ossy/booking/data/booking-list`
|
|
9
|
+
* @returns {Promise<object | null>}
|
|
10
|
+
*/
|
|
11
|
+
export async function getProjection (scopeId, projectionId) {
|
|
12
|
+
if (!scopeId || !projectionId) return null
|
|
13
|
+
const doc = await withMongoReconnect(() =>
|
|
14
|
+
ProjectionRebuild.Collection.findOne({ id: scopeId, type: projectionId }),
|
|
15
|
+
)
|
|
16
|
+
return doc?.state ?? null
|
|
17
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { matchesSource } from '@ossy/fold'
|
|
2
|
+
import { EventStore } from './event-store.js'
|
|
3
|
+
import { Mongo, withMongoReconnect } from './mongodb.js'
|
|
4
|
+
import { createLogger } from '@ossy/observability'
|
|
5
|
+
|
|
6
|
+
const log = createLogger('event-store')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ADR 0008 projection aggregate rebuild — derived read models keyed by `scopeId`.
|
|
10
|
+
*/
|
|
11
|
+
export class ProjectionRebuild {
|
|
12
|
+
|
|
13
|
+
/** @type {Function[]} */
|
|
14
|
+
static _projections = []
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ id: string, Aggregate: Function }} mod
|
|
18
|
+
*/
|
|
19
|
+
static registerProjection (mod) {
|
|
20
|
+
const { Aggregate: ProjectionClass } = mod
|
|
21
|
+
if (ProjectionClass?.kind !== 'projection' || typeof ProjectionClass.Apply !== 'function') {
|
|
22
|
+
log.warn('[ProjectionRebuild] registerProjection: expected kind=projection with Apply()')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
ProjectionRebuild._projections.push(ProjectionClass)
|
|
26
|
+
log.debug(`[ProjectionRebuild] Registered projection: ${mod.id ?? ProjectionClass.ProjectionId}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static get Collection () {
|
|
30
|
+
return Mongo.db.collection('aggregates')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async dispatch (event) {
|
|
34
|
+
if (!event?.resourceId) return
|
|
35
|
+
|
|
36
|
+
for (const ProjectionClass of ProjectionRebuild._projections) {
|
|
37
|
+
const sources = ProjectionClass.sources ?? []
|
|
38
|
+
if (!sources.some(source => matchesSource(source, event))) continue
|
|
39
|
+
|
|
40
|
+
const scopeId = typeof ProjectionClass.scopeFromEvent === 'function'
|
|
41
|
+
? ProjectionClass.scopeFromEvent(event)
|
|
42
|
+
: null
|
|
43
|
+
if (!scopeId) continue
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await ProjectionRebuild._applyOne(ProjectionClass, scopeId, event)
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.error(`[ProjectionRebuild] failed for ${ProjectionClass.ProjectionId}`, undefined, err)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Replay all matching events and rebuild projection snapshots (startup). */
|
|
54
|
+
static async rebuildAll () {
|
|
55
|
+
for (const ProjectionClass of ProjectionRebuild._projections) {
|
|
56
|
+
const sources = ProjectionClass.sources ?? []
|
|
57
|
+
if (!sources.length) continue
|
|
58
|
+
|
|
59
|
+
const orClauses = sources.map(s => {
|
|
60
|
+
const clause = { type: s.type, event: s.event }
|
|
61
|
+
return clause
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const events = await EventStore.Collection.find({ $or: orClauses })
|
|
65
|
+
.sort({ created: 1, version: 1 })
|
|
66
|
+
.toArray()
|
|
67
|
+
|
|
68
|
+
const byScope = new Map()
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
if (!sources.some(source => matchesSource(source, event))) continue
|
|
71
|
+
const scopeId = ProjectionClass.scopeFromEvent?.(event)
|
|
72
|
+
if (!scopeId) continue
|
|
73
|
+
if (!byScope.has(scopeId)) byScope.set(scopeId, [])
|
|
74
|
+
byScope.get(scopeId).push(event)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const [scopeId, scopeEvents] of byScope) {
|
|
78
|
+
let state = ProjectionClass.initialState?.(scopeId) ?? {}
|
|
79
|
+
for (const event of scopeEvents) {
|
|
80
|
+
state = ProjectionClass.Apply(event, state) ?? state
|
|
81
|
+
}
|
|
82
|
+
const version = scopeEvents.length
|
|
83
|
+
const last = scopeEvents[scopeEvents.length - 1]
|
|
84
|
+
const cursor = last ? { eventId: last.id, eventVersion: last.version } : null
|
|
85
|
+
|
|
86
|
+
await withMongoReconnect(() =>
|
|
87
|
+
ProjectionRebuild.Collection.updateOne(
|
|
88
|
+
{ id: scopeId, type: ProjectionClass.ProjectionId },
|
|
89
|
+
{
|
|
90
|
+
$set: {
|
|
91
|
+
id: scopeId,
|
|
92
|
+
type: ProjectionClass.ProjectionId,
|
|
93
|
+
kind: 'projection',
|
|
94
|
+
version,
|
|
95
|
+
state: { ...state, version, cursor },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{ upsert: true },
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
log.info(`[ProjectionRebuild] Rebuilt ${ProjectionClass.ProjectionId} (${byScope.size} scopes)`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static async _applyOne (ProjectionClass, scopeId, event) {
|
|
108
|
+
const projectionId = ProjectionClass.ProjectionId
|
|
109
|
+
const snapshot = await withMongoReconnect(() =>
|
|
110
|
+
ProjectionRebuild.Collection.findOne({ id: scopeId, type: projectionId }),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const state = snapshot?.state ?? ProjectionClass.initialState?.(scopeId) ?? {}
|
|
114
|
+
const next = ProjectionClass.Apply(event, state)
|
|
115
|
+
if (!next) return
|
|
116
|
+
|
|
117
|
+
const version = (snapshot?.version ?? 0) + 1
|
|
118
|
+
const cursor = { eventId: event.id, eventVersion: event.version }
|
|
119
|
+
|
|
120
|
+
await withMongoReconnect(() =>
|
|
121
|
+
ProjectionRebuild.Collection.updateOne(
|
|
122
|
+
{ id: scopeId, type: projectionId },
|
|
123
|
+
{
|
|
124
|
+
$set: {
|
|
125
|
+
id: scopeId,
|
|
126
|
+
type: projectionId,
|
|
127
|
+
kind: 'projection',
|
|
128
|
+
version,
|
|
129
|
+
state: { ...next, version, cursor },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{ upsert: true },
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|