@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/src/aggregate.js CHANGED
@@ -1,157 +1,60 @@
1
1
  import { nanoid } from 'nanoid'
2
- import { Mongo, withMongoReconnect } from './mongodb.js'
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
- * Utility class that helps interact with event streams.
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 Mongo.db.collection('aggregates')
12
+ static get Collection () {
13
+ return SchemaStream.Collection
17
14
  }
18
15
 
19
- /**
20
- * Start interacting with an event stream
21
- *
22
- * @param {Class} AggregateRoot - Root aggregate (class that aggregates the events)
23
- * @param {string} id - Id of aggregate - aggregateId
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('[Aggregate] Creating new aggregate event stream')
45
- return Aggregate.Add(identifier)(new Aggregate(AggregateRoot, identifier.aggregateId || nanoid(), []))
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.error('[Aggregate] No recognizable identifier provided')
49
- return Promise.reject()
35
+ log.info(`[Aggregate][Of] ${AggregateRoot.SchemaId}`)
36
+ return SchemaStream.Of(AggregateRoot, identifier)
50
37
  }
51
38
 
52
- static Find(identifier) {
53
- log.info(`[Aggregate][Find()] Fetching aggregate for ${identifier}`)
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 aggregate => {
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 aggregate => {
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 View(view) {
125
- return aggregate => {
126
- log.debug(`[Aggregate][View] Building view for ${aggregate.type} ${aggregate.id}`)
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
- * Use the Aggregate.Of(type, id) static method instead
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
+ })
@@ -4,108 +4,101 @@ import { createLogger } from '@ossy/observability'
4
4
  const log = createLogger('event-store')
5
5
 
6
6
  /**
7
- * Database queries related to workspace events
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 AppendEvent(event) {
17
- log.info('[EventStore] Appending event')
18
- log.debug('[EventStore] Event', { event })
66
+ static AppendResourceEvent (event) {
67
+ return appendResourceEvent(event)
68
+ }
19
69
 
20
- return withMongoReconnect(() => EventStore.Collection.insertOne(event))
21
- .then(insertResult => {
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 FindEvent(query) {
33
- log.info('[EventStore] Searching for event')
34
- log.debug('[EventStore] Query', { query })
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 => !!event ? event : Promise.reject())
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: { aggregateVersion: 1 } }).toArray()
91
+ EventStore.Collection.find(query, { sort: { version: 1, created: 1 } }).toArray(),
50
92
  )
51
- .then(events => !!events?.length ? events : Promise.reject())
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
+ }