@ossy/event-store 3.7.0 → 3.11.0
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 +2 -2
- package/src/mongodb.js +22 -3
- package/src/mongodb.spec.js +79 -0
- package/src/projection-rebuild.js +46 -11
- package/src/projection-rebuild.spec.js +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/event-store",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "Ossy Event Store — Aggregate, EventStore, and MongoDB client",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"src"
|
|
39
39
|
],
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "53ff8456920e09151c9d88df4a6c3ee958d811eb"
|
|
41
41
|
}
|
package/src/mongodb.js
CHANGED
|
@@ -13,6 +13,7 @@ if (!globalThis.require) {
|
|
|
13
13
|
const log = createLogger('event-store')
|
|
14
14
|
|
|
15
15
|
const DEFAULT_MONGO_URL = 'mongodb://mongodb:27017/'
|
|
16
|
+
const DEFAULT_MAX_POOL_SIZE = 50
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Resolve MongoDB URL at call time so dotenv can load before the first connection.
|
|
@@ -29,12 +30,30 @@ export function resolveMongoUrl(url = process.env.DB_URL) {
|
|
|
29
30
|
return resolved
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Driver default maxPoolSize is 100; we cap lower. See MONGO_MAX_POOL_SIZE / MONGO_MAX_IDLE_TIME_MS. */
|
|
34
|
+
export function resolveMongoClientOptions(env = process.env) {
|
|
35
|
+
const maxPoolSize = parsePositiveInt(env.MONGO_MAX_POOL_SIZE, DEFAULT_MAX_POOL_SIZE)
|
|
36
|
+
/** @type {import('mongodb').MongoClientOptions} */
|
|
37
|
+
const options = {
|
|
38
|
+
serverSelectionTimeoutMS: 30_000,
|
|
39
|
+
maxPoolSize,
|
|
40
|
+
}
|
|
41
|
+
const maxIdleTimeMS = parsePositiveInt(env.MONGO_MAX_IDLE_TIME_MS, 0)
|
|
42
|
+
if (maxIdleTimeMS > 0) {
|
|
43
|
+
options.maxIdleTimeMS = maxIdleTimeMS
|
|
44
|
+
}
|
|
45
|
+
return options
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parsePositiveInt(value, fallback) {
|
|
49
|
+
const n = Number.parseInt(value ?? '', 10)
|
|
50
|
+
return Number.isFinite(n) && n > 0 ? n : fallback
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
let mongoClient = null
|
|
33
54
|
|
|
34
55
|
function createClient() {
|
|
35
|
-
return new MongoClient(resolveMongoUrl(),
|
|
36
|
-
serverSelectionTimeoutMS: 30_000,
|
|
37
|
-
})
|
|
56
|
+
return new MongoClient(resolveMongoUrl(), resolveMongoClientOptions())
|
|
38
57
|
}
|
|
39
58
|
|
|
40
59
|
export function isMongoTopologyClosedError(error) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from '@jest/globals'
|
|
2
|
+
import {
|
|
3
|
+
Mongo,
|
|
4
|
+
resolveMongoClientOptions,
|
|
5
|
+
resolveMongoUrl,
|
|
6
|
+
} from './mongodb.js'
|
|
7
|
+
|
|
8
|
+
describe('resolveMongoUrl', () => {
|
|
9
|
+
it('adds directConnection for localhost URLs', () => {
|
|
10
|
+
expect(resolveMongoUrl('mongodb://127.0.0.1:27017/')).toContain('directConnection=true')
|
|
11
|
+
expect(resolveMongoUrl('mongodb://localhost:27017/test')).toContain('directConnection=true')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('leaves mongodb+srv URLs unchanged', () => {
|
|
15
|
+
const url = 'mongodb+srv://user:pass@cluster.example.net/'
|
|
16
|
+
expect(resolveMongoUrl(url)).toBe(url)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('resolveMongoClientOptions', () => {
|
|
21
|
+
it('defaults to a bounded pool that still leaves headroom for concurrent API work', () => {
|
|
22
|
+
expect(resolveMongoClientOptions({})).toEqual({
|
|
23
|
+
serverSelectionTimeoutMS: 30_000,
|
|
24
|
+
maxPoolSize: 50,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('reads MONGO_MAX_POOL_SIZE and optional MONGO_MAX_IDLE_TIME_MS', () => {
|
|
29
|
+
expect(resolveMongoClientOptions({
|
|
30
|
+
MONGO_MAX_POOL_SIZE: '5',
|
|
31
|
+
MONGO_MAX_IDLE_TIME_MS: '15000',
|
|
32
|
+
})).toEqual({
|
|
33
|
+
serverSelectionTimeoutMS: 30_000,
|
|
34
|
+
maxPoolSize: 5,
|
|
35
|
+
maxIdleTimeMS: 15_000,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('omits maxIdleTimeMS unless env is a positive int', () => {
|
|
40
|
+
expect(resolveMongoClientOptions({
|
|
41
|
+
MONGO_MAX_POOL_SIZE: '40',
|
|
42
|
+
MONGO_MAX_IDLE_TIME_MS: '0',
|
|
43
|
+
})).toEqual({
|
|
44
|
+
serverSelectionTimeoutMS: 30_000,
|
|
45
|
+
maxPoolSize: 40,
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('falls back when env values are invalid', () => {
|
|
50
|
+
expect(resolveMongoClientOptions({
|
|
51
|
+
MONGO_MAX_POOL_SIZE: '0',
|
|
52
|
+
MONGO_MAX_IDLE_TIME_MS: 'nope',
|
|
53
|
+
})).toEqual({
|
|
54
|
+
serverSelectionTimeoutMS: 30_000,
|
|
55
|
+
maxPoolSize: 50,
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('Mongo singleton', () => {
|
|
61
|
+
afterEach(async () => {
|
|
62
|
+
await Mongo.closeConnection()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('reuses one client across getters', () => {
|
|
66
|
+
const a = Mongo.Client
|
|
67
|
+
const b = Mongo.Client
|
|
68
|
+
expect(a).toBe(b)
|
|
69
|
+
expect(a.options.maxPoolSize).toBe(50)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('resetClient replaces the singleton', async () => {
|
|
73
|
+
const first = Mongo.Client
|
|
74
|
+
Mongo.resetClient()
|
|
75
|
+
const second = Mongo.Client
|
|
76
|
+
expect(second).not.toBe(first)
|
|
77
|
+
expect(second.options.maxPoolSize).toBe(50)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -5,6 +5,50 @@ import { createLogger } from '@ossy/observability'
|
|
|
5
5
|
|
|
6
6
|
const log = createLogger('event-store')
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Workspace (or other) scope for a projection event.
|
|
10
|
+
* When `scopeByResource` is passed, later events on the same resourceId inherit
|
|
11
|
+
* the scope from an earlier event (Started → Completed without repeating belongsTo).
|
|
12
|
+
*
|
|
13
|
+
* @param {{ scopeFromEvent?: (event: object) => string | null }} ProjectionClass
|
|
14
|
+
* @param {object} event
|
|
15
|
+
* @param {Map<string, string> | null} [scopeByResource]
|
|
16
|
+
* @returns {string | null}
|
|
17
|
+
*/
|
|
18
|
+
export function resolveProjectionScope (ProjectionClass, event, scopeByResource = null) {
|
|
19
|
+
let scopeId = typeof ProjectionClass.scopeFromEvent === 'function'
|
|
20
|
+
? ProjectionClass.scopeFromEvent(event)
|
|
21
|
+
: null
|
|
22
|
+
if (!scopeId && event?.resourceId) {
|
|
23
|
+
scopeId = scopeByResource?.get(event.resourceId) ?? null
|
|
24
|
+
}
|
|
25
|
+
if (scopeId && event?.resourceId && scopeByResource) {
|
|
26
|
+
scopeByResource.set(event.resourceId, scopeId)
|
|
27
|
+
}
|
|
28
|
+
return scopeId || null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Group matching events by projection scope, inheriting scope along a resource stream.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ sources?: object[], scopeFromEvent?: Function }} ProjectionClass
|
|
35
|
+
* @param {object[]} events
|
|
36
|
+
* @returns {Map<string, object[]>}
|
|
37
|
+
*/
|
|
38
|
+
export function groupEventsByScope (ProjectionClass, events) {
|
|
39
|
+
const sources = ProjectionClass.sources ?? []
|
|
40
|
+
const byScope = new Map()
|
|
41
|
+
const scopeByResource = new Map()
|
|
42
|
+
for (const event of events) {
|
|
43
|
+
if (!sources.some(source => matchesSource(source, event))) continue
|
|
44
|
+
const scopeId = resolveProjectionScope(ProjectionClass, event, scopeByResource)
|
|
45
|
+
if (!scopeId) continue
|
|
46
|
+
if (!byScope.has(scopeId)) byScope.set(scopeId, [])
|
|
47
|
+
byScope.get(scopeId).push(event)
|
|
48
|
+
}
|
|
49
|
+
return byScope
|
|
50
|
+
}
|
|
51
|
+
|
|
8
52
|
/**
|
|
9
53
|
* Document id for projection snapshots — must not collide with entity resourceIds
|
|
10
54
|
* (aggregates collection has a unique index on `id`).
|
|
@@ -49,9 +93,7 @@ export class ProjectionRebuild {
|
|
|
49
93
|
const sources = ProjectionClass.sources ?? []
|
|
50
94
|
if (!sources.some(source => matchesSource(source, event))) continue
|
|
51
95
|
|
|
52
|
-
const scopeId =
|
|
53
|
-
? ProjectionClass.scopeFromEvent(event)
|
|
54
|
-
: null
|
|
96
|
+
const scopeId = resolveProjectionScope(ProjectionClass, event)
|
|
55
97
|
if (!scopeId) continue
|
|
56
98
|
|
|
57
99
|
try {
|
|
@@ -77,14 +119,7 @@ export class ProjectionRebuild {
|
|
|
77
119
|
.sort({ created: 1, version: 1 })
|
|
78
120
|
.toArray()
|
|
79
121
|
|
|
80
|
-
const byScope =
|
|
81
|
-
for (const event of events) {
|
|
82
|
-
if (!sources.some(source => matchesSource(source, event))) continue
|
|
83
|
-
const scopeId = ProjectionClass.scopeFromEvent?.(event)
|
|
84
|
-
if (!scopeId) continue
|
|
85
|
-
if (!byScope.has(scopeId)) byScope.set(scopeId, [])
|
|
86
|
-
byScope.get(scopeId).push(event)
|
|
87
|
-
}
|
|
122
|
+
const byScope = groupEventsByScope(ProjectionClass, events)
|
|
88
123
|
|
|
89
124
|
for (const [scopeId, scopeEvents] of byScope) {
|
|
90
125
|
let state = ProjectionClass.initialState?.(scopeId) ?? {}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { groupEventsByScope, resolveProjectionScope } from './projection-rebuild.js'
|
|
3
|
+
|
|
4
|
+
const TaskRunList = {
|
|
5
|
+
sources: [
|
|
6
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Started' },
|
|
7
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Completed' },
|
|
8
|
+
{ type: '@ossy/platform/schema/task-run', event: 'Failed' },
|
|
9
|
+
],
|
|
10
|
+
scopeFromEvent (event) {
|
|
11
|
+
return event.payload?.belongsTo ?? null
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('resolveProjectionScope', () => {
|
|
16
|
+
it('uses payload.belongsTo when present', () => {
|
|
17
|
+
expect(resolveProjectionScope(TaskRunList, {
|
|
18
|
+
resourceId: 'run1',
|
|
19
|
+
payload: { belongsTo: 'ws1' },
|
|
20
|
+
})).toBe('ws1')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('skips events with no scope (live Completed without belongsTo)', () => {
|
|
24
|
+
expect(resolveProjectionScope(TaskRunList, {
|
|
25
|
+
resourceId: 'run1',
|
|
26
|
+
payload: { durationMs: 12 },
|
|
27
|
+
})).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('inherits scope from an earlier event on the same resource', () => {
|
|
31
|
+
const scopeByResource = new Map()
|
|
32
|
+
expect(resolveProjectionScope(TaskRunList, {
|
|
33
|
+
resourceId: 'run1',
|
|
34
|
+
payload: { belongsTo: 'ws1' },
|
|
35
|
+
}, scopeByResource)).toBe('ws1')
|
|
36
|
+
|
|
37
|
+
expect(resolveProjectionScope(TaskRunList, {
|
|
38
|
+
resourceId: 'run1',
|
|
39
|
+
payload: { durationMs: 12 },
|
|
40
|
+
}, scopeByResource)).toBe('ws1')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('groupEventsByScope', () => {
|
|
45
|
+
it('keeps Completed with the workspace of Started when belongsTo is omitted', () => {
|
|
46
|
+
const byScope = groupEventsByScope(TaskRunList, [
|
|
47
|
+
{
|
|
48
|
+
resourceId: 'run1',
|
|
49
|
+
type: '@ossy/platform/schema/task-run',
|
|
50
|
+
event: 'Started',
|
|
51
|
+
payload: { belongsTo: 'ws1', taskId: 't1' },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
resourceId: 'run1',
|
|
55
|
+
type: '@ossy/platform/schema/task-run',
|
|
56
|
+
event: 'Completed',
|
|
57
|
+
payload: { durationMs: 40 },
|
|
58
|
+
},
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
expect([...byScope.keys()]).toEqual(['ws1'])
|
|
62
|
+
expect(byScope.get('ws1')).toHaveLength(2)
|
|
63
|
+
expect(byScope.get('ws1').map(e => e.event)).toEqual(['Started', 'Completed'])
|
|
64
|
+
})
|
|
65
|
+
})
|