@ossy/event-store 1.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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@ossy/event-store",
3
+ "version": "1.0.1",
4
+ "description": "Ossy Event Store — Aggregate, EventStore, and MongoDB client",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ossy-se/packages.git"
8
+ },
9
+ "type": "module",
10
+ "ossy": {
11
+ "src": "./src"
12
+ },
13
+ "main": "./src/index.js",
14
+ "exports": {
15
+ ".": "./src/index.js"
16
+ },
17
+ "private": false,
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org"
21
+ },
22
+ "keywords": [],
23
+ "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "mongodb": "^7.2.0",
27
+ "nanoid": "^5.1.11"
28
+ },
29
+ "files": [
30
+ "src"
31
+ ],
32
+ "gitHead": "b8cbdc545a659e96af8948aa08a89316a2c2d193"
33
+ }
@@ -0,0 +1,71 @@
1
+ import { Aggregate } from './aggregate.js'
2
+ import { EventStore } from './event-store.js'
3
+
4
+ const _debug = Boolean(process.env.DEBUG)
5
+
6
+ function _log(message) { console.log(message) }
7
+ function _warn(message) { console.warn(message) }
8
+ function _logDebug(message) {
9
+ if (!_debug) return
10
+ console.log(message)
11
+ }
12
+
13
+ export class AggregateRebuild {
14
+
15
+ /** @type {Record<string, Function>} */
16
+ static _aggregateMap = {}
17
+
18
+ /**
19
+ * Registers a pre-loaded aggregate module. Called by the platform server at
20
+ * startup for each aggregate entry in the manifest, following the same pattern
21
+ * as task and resource-template registration.
22
+ *
23
+ * @param {{ id: string, Aggregate: Function }} mod - The aggregate bundle module.
24
+ */
25
+ static registerAggregate(mod) {
26
+ const { id, Aggregate: AggregateClass } = mod
27
+ if (typeof AggregateClass !== 'function' || !id) {
28
+ _warn('[AggregateRebuild] registerAggregate: invalid module — expected { id, Aggregate }')
29
+ return
30
+ }
31
+ AggregateRebuild._aggregateMap[id] = AggregateClass
32
+ _logDebug(`[AggregateRebuild] Registered aggregate: ${id}`)
33
+ }
34
+
35
+ static async BuildAndSave(aggregateType, aggregateId) {
36
+ const AggregateRoot = AggregateRebuild._aggregateMap[aggregateType]
37
+
38
+ if (!AggregateRoot) {
39
+ _logDebug(`[AggregateRebuild][BuildAndSave] Skipping unregistered aggregate type: ${aggregateType}`)
40
+ return
41
+ }
42
+
43
+ return Aggregate.Of(AggregateRoot, aggregateId)
44
+ .then(Aggregate.Save())
45
+ }
46
+
47
+ static async BuildAndSaveAll() {
48
+ _logDebug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
49
+
50
+ const knownTypes = new Set(Object.keys(AggregateRebuild._aggregateMap))
51
+
52
+ if (knownTypes.size === 0) {
53
+ _warn('[AggregateRebuild][BuildAndSaveAll] No aggregates registered — skipping rebuild')
54
+ return
55
+ }
56
+
57
+ return EventStore.GetEventStreams()
58
+ .then(streams => Promise.allSettled(
59
+ streams
60
+ .filter(({ aggregateType }) => knownTypes.has(aggregateType))
61
+ .map(({ aggregateType, aggregateId }) => AggregateRebuild.BuildAndSave(aggregateType, aggregateId))
62
+ ))
63
+ .then(results => {
64
+ const failed = results.filter(result => result.status === 'rejected')
65
+ const success = results.filter(result => result.status === 'fulfilled')
66
+ _log('[AggregateRebuild][BuildAndSaveAll] Finished building aggregates')
67
+ _log(`[AggregateRebuild][BuildAndSaveAll] Failed: ${failed.length}`)
68
+ _log(`[AggregateRebuild][BuildAndSaveAll] Success: ${success.length}`)
69
+ })
70
+ }
71
+ }
@@ -0,0 +1,164 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { Mongo, withMongoReconnect } from './mongodb.js'
3
+ import { EventStore } from './event-store.js'
4
+
5
+ const _debug = Boolean(process.env.DEBUG)
6
+
7
+ function _log(message) { console.log(message) }
8
+ function _logError(message) { console.error(message) }
9
+ function _logDebug(message) {
10
+ if (!_debug) return
11
+ console.log(message)
12
+ }
13
+
14
+ /**
15
+ * Utility class that helps interact with event streams.
16
+ * Use this class if you want to create a new stream, add events to an existing stream or view streams.
17
+ * @class
18
+ */
19
+ export class Aggregate {
20
+
21
+ static get Collection() {
22
+ return Mongo.db.collection('aggregates')
23
+ }
24
+
25
+ /**
26
+ * Start interacting with an event stream
27
+ *
28
+ * @param {Class} AggregateRoot - Root aggregate (class that aggregates the events)
29
+ * @param {string} id - Id of aggregate - aggregateId
30
+ * @return {Aggregate} - Instance of Aggregate
31
+ *
32
+ * @example
33
+ * Aggregate.Of(User, userId)
34
+ * Aggregate.Of(Workspace, workspaceId)
35
+ */
36
+ static Of(AggregateRoot, identifier) {
37
+
38
+ _log(`[Aggregate][Of()][${AggregateRoot?.AggregateType}] Creating aggregate root`)
39
+
40
+ if (typeof identifier === 'string') {
41
+
42
+ return Aggregate.Find(identifier)
43
+ .then(aggregate => {
44
+ return EventStore.GetEventStream({ aggregateId: identifier, fromVersion: aggregate?.version })
45
+ .then(events => new Aggregate(AggregateRoot, identifier, events, aggregate))
46
+ })
47
+ }
48
+
49
+ if (typeof identifier === 'object') {
50
+ _log('[Aggregate] Creating new aggregate event stream')
51
+ return Aggregate.Add(identifier)(new Aggregate(AggregateRoot, identifier.aggregateId || nanoid(), []))
52
+ }
53
+
54
+ _logError('[Aggregate] No recognizable identifier provided')
55
+ return Promise.reject()
56
+ }
57
+
58
+ static Find(identifier) {
59
+ _log(`[Aggregate][Find()] Fetching aggregate for ${identifier}`)
60
+ return withMongoReconnect(() => Aggregate.Collection.findOne({ id: identifier }))
61
+ .then(aggregate => {
62
+ if (!aggregate) {
63
+ _logDebug(`[Aggregate][Find()] No aggregate found for ${identifier}`)
64
+ }
65
+ return aggregate
66
+ })
67
+ }
68
+
69
+ static Add(event) {
70
+ return aggregate => {
71
+
72
+ return EventStore.AppendEvent({
73
+ ...event,
74
+ id: nanoid(),
75
+ created: Date.now(),
76
+ createdBy: event.createdBy,
77
+ aggregateType: aggregate.type,
78
+ aggregateId: aggregate.id,
79
+ aggregateVersion: aggregate.version + 1,
80
+ })
81
+ .then(savedEvent => {
82
+ aggregate.version = savedEvent.aggregateVersion,
83
+ aggregate.events = [...aggregate.events, savedEvent]
84
+ return aggregate
85
+ })
86
+ }
87
+
88
+ }
89
+
90
+ static Save() {
91
+ return aggregate => {
92
+ _logDebug(`[Aggregate][Save] Saving state for ${aggregate.type} ${aggregate.id}`)
93
+
94
+ if (!aggregate.events || aggregate.events.length === 0) {
95
+ _logDebug(`[Aggregate][Save] No events to save for ${aggregate.type} ${aggregate.id}`)
96
+ return Promise.resolve()
97
+ }
98
+
99
+ const latestEventVersion = [...aggregate.events].sort((a, b) => a.aggregateVersion - b.aggregateVersion).pop()?.aggregateVersion
100
+
101
+ if (latestEventVersion < aggregate.version) {
102
+ _logDebug(`[Aggregate][Save] No new events to save for ${aggregate.type} ${aggregate.id}`)
103
+ return Promise.resolve()
104
+ }
105
+
106
+ const state = aggregate.View(aggregate.events, aggregate.state)
107
+
108
+ return withMongoReconnect(() =>
109
+ Aggregate.Collection.updateOne({ id: aggregate.id }, {
110
+ $set: {
111
+ id: aggregate.id,
112
+ version: latestEventVersion,
113
+ type: aggregate.type,
114
+ state: state
115
+ }
116
+ }, { upsert: true })
117
+ )
118
+ .catch(error => {
119
+ _logError(`[Aggregate][Save] Failed to save state for ${aggregate.type} ${aggregate.id}`)
120
+ console.error(error)
121
+ })
122
+ }
123
+ }
124
+
125
+ static Validate(validator) {
126
+ return aggregate => Promise.resolve()
127
+ .then(() => validator(aggregate.events, aggregate.state))
128
+ .then(() => aggregate)
129
+ }
130
+
131
+ static View(view) {
132
+ return aggregate => {
133
+ _logDebug(`[Aggregate][View] Building view for ${aggregate.type} ${aggregate.id}`)
134
+
135
+ if (typeof view === 'function') {
136
+ return view(aggregate.events, aggregate.state)
137
+ }
138
+
139
+ return aggregate.View(aggregate.events, aggregate.state)
140
+ }
141
+
142
+ }
143
+
144
+ /**
145
+ * Use the Aggregate.Of(type, id) static method instead
146
+ *
147
+ * @param {string} id - Id of aggregate - aggregateId
148
+ * @param {string} root - root aggregate (class that aggregates the events)
149
+ * @param {Event[]} events - The events that make up the stream
150
+ * @param {Object} aggregate - The object representing the aggregate of all the events
151
+ */
152
+ constructor(AggregateRoot, id, events, aggregate) {
153
+ _logDebug(`[Aggregate][Constructor] Assembling ${AggregateRoot.AggregateType} ${id}`)
154
+
155
+ this.id = id;
156
+ this.version = aggregate?.version || 0
157
+ this.type = AggregateRoot.AggregateType
158
+ this.events = events || []
159
+ this.state = aggregate?.state
160
+
161
+ this.View = AggregateRoot.View
162
+ }
163
+
164
+ }
@@ -0,0 +1,131 @@
1
+ import { Mongo, withMongoReconnect } from './mongodb.js'
2
+
3
+ const _debug = Boolean(process.env.DEBUG)
4
+
5
+ function _log(message) { console.log(message) }
6
+ function _logError(message, error) {
7
+ console.error(message)
8
+ if (error) console.error('[Reason]:', error)
9
+ }
10
+ function _logDebug(message, data) {
11
+ if (!_debug) return
12
+ console.log(message)
13
+ if (data !== undefined) console.log('[DEBUG DATA]:', data)
14
+ }
15
+
16
+ /**
17
+ * Database queries related to workspace events
18
+ * @class
19
+ */
20
+ export class EventStore {
21
+
22
+ static get Collection() {
23
+ return Mongo.db.collection('eventstore')
24
+ }
25
+
26
+ static AppendEvent(event) {
27
+ _log('[EventStore] Appending event')
28
+ _logDebug('[EventStore] Event', event)
29
+
30
+ return withMongoReconnect(() => EventStore.Collection.insertOne(event))
31
+ .then(insertResult => {
32
+ return insertResult.acknowledged
33
+ ? Promise.resolve(event)
34
+ : Promise.reject()
35
+ })
36
+ .catch(error => {
37
+ _logError('[EventStore] Could not append event', error)
38
+ return Promise.reject()
39
+ })
40
+ }
41
+
42
+ static FindEvent(query) {
43
+ _log('[EventStore] Searching for event')
44
+ _logDebug('[EventStore] Query', query)
45
+
46
+ return withMongoReconnect(() => EventStore.Collection.findOne(query))
47
+ .then(event => !!event ? event : Promise.reject())
48
+ .catch(error => {
49
+ _logError('[EventStore] No event found', error)
50
+ return Promise.reject()
51
+ })
52
+ }
53
+
54
+ static FindEvents(query) {
55
+ _log('[EventStore] Searching for events')
56
+ _logDebug('[EventStore] Query', query)
57
+
58
+ return withMongoReconnect(() =>
59
+ EventStore.Collection.find(query, { sort: { aggregateVersion: 1 } }).toArray()
60
+ )
61
+ .then(events => !!events?.length ? events : Promise.reject())
62
+ .catch(error => {
63
+ _logError('[EventStore] No events found', error)
64
+ return Promise.reject()
65
+ })
66
+ }
67
+
68
+ static GetEventStream({
69
+ aggregateId,
70
+ fromVersion = 0
71
+ }) {
72
+ _logDebug(`[EventStore][GetEventStream()] Fetching stream ${aggregateId} from version ${fromVersion}`)
73
+
74
+ const query = {
75
+ aggregateId: aggregateId,
76
+ aggregateVersion: { '$gt': fromVersion }
77
+ }
78
+
79
+ const options = {
80
+ sort: { aggregateVersion: 1 }
81
+ }
82
+
83
+ return withMongoReconnect(() => EventStore.Collection.find(query, options).toArray())
84
+ .then(events => {
85
+ _logDebug(`[EventStore][GetEventStream()] Found ${events.length} events for ${aggregateId}`)
86
+ return events
87
+ })
88
+ }
89
+
90
+ static GetEventStreams() {
91
+ _log('[EventStore][GetEventStreams()] Fetching event streams')
92
+
93
+ return EventStore.Aggregate([
94
+ {
95
+ '$group': {
96
+ '_id': {
97
+ 'aggregateId': '$aggregateId',
98
+ 'aggregateType': '$aggregateType'
99
+ }
100
+ }
101
+ },
102
+ {
103
+ '$project': {
104
+ '_id': 0,
105
+ 'aggregateId': '$_id.aggregateId',
106
+ 'aggregateType': '$_id.aggregateType'
107
+ }
108
+ }
109
+ ])
110
+ .then(result => {
111
+ _logDebug(`[EventStore][GetEventStreams()] found ${result.length} streams`)
112
+ return result
113
+ })
114
+ }
115
+
116
+ static Aggregate(pipeline) {
117
+ _log('[EventStore] Running aggregation pipeline')
118
+
119
+ return withMongoReconnect(() => EventStore.Collection.aggregate(pipeline).toArray())
120
+ .then(events => (Array.isArray(events) ? events : []))
121
+ .catch(error => {
122
+ _logError('[EventStore] Aggregation failed', error)
123
+ return Promise.reject(error)
124
+ })
125
+ }
126
+
127
+ static CloseDbConnection() {
128
+ return Mongo.closeConnection()
129
+ }
130
+
131
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './mongodb.js'
2
+ export * from './event-store.js'
3
+ export * from './aggregate.js'
4
+ export * from './aggregate-rebuild.js'
package/src/mongodb.js ADDED
@@ -0,0 +1,72 @@
1
+ import { createRequire } from 'module'
2
+ import { MongoClient } from 'mongodb'
3
+
4
+ // When bundled as ESM by @ossy/app (Rollup + @rollup/plugin-commonjs), the
5
+ // MongoDB driver's resolveRuntimeAdapters references `require` as a free
6
+ // variable to detect the Node.js runtime. Provide it on globalThis so the
7
+ // bundled CJS code can find it without changing the build configuration.
8
+ if (!globalThis.require) {
9
+ globalThis.require = createRequire(import.meta.url)
10
+ }
11
+
12
+ const MONGO_URL = process.env.DB_URL || 'mongodb://mongodb:27017/'
13
+ const DB_NAME = process.env.DB_NAME || 'test'
14
+
15
+ let mongoClient = null
16
+
17
+ function createClient() {
18
+ return new MongoClient(MONGO_URL, {
19
+ serverSelectionTimeoutMS: 30_000,
20
+ })
21
+ }
22
+
23
+ export function isMongoTopologyClosedError(error) {
24
+ if (!error || typeof error !== 'object') return false
25
+ if (error.name === 'MongoTopologyClosedError') return true
26
+ if (typeof error.message === 'string' && error.message.includes('Topology is closed')) return true
27
+ return false
28
+ }
29
+
30
+ /**
31
+ * Run a DB operation; if the driver closed the topology after a prior failure, reset the client and retry once.
32
+ */
33
+ export function withMongoReconnect(run) {
34
+ return run().catch((err) => {
35
+ if (isMongoTopologyClosedError(err)) {
36
+ console.log('[Mongo] Topology closed — resetting client and retrying once')
37
+ Mongo.resetClient()
38
+ return run()
39
+ }
40
+ return Promise.reject(err)
41
+ })
42
+ }
43
+
44
+ export class Mongo {
45
+ static get Client() {
46
+ if (!mongoClient) {
47
+ mongoClient = createClient()
48
+ }
49
+ return mongoClient
50
+ }
51
+
52
+ static get db() {
53
+ return Mongo.Client.db(DB_NAME)
54
+ }
55
+
56
+ static resetClient() {
57
+ const prev = mongoClient
58
+ mongoClient = createClient()
59
+ if (prev) {
60
+ prev.close().catch(() => {})
61
+ }
62
+ console.log('[Mongo] New MongoClient instance created')
63
+ }
64
+
65
+ /** Used by tests / graceful shutdown; resets singleton so a later operation opens a new client. */
66
+ static closeConnection() {
67
+ if (!mongoClient) return Promise.resolve()
68
+ const c = mongoClient
69
+ mongoClient = null
70
+ return c.close().catch(() => {})
71
+ }
72
+ }