@ossy/event-store 3.6.2 → 3.8.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/README.md +9 -2
- package/package.json +2 -2
- package/src/ensure-indexes.js +6 -0
- package/src/projection-rebuild.js +46 -11
- package/src/projection-rebuild.spec.js +65 -0
package/README.md
CHANGED
|
@@ -155,13 +155,20 @@ import { Mongo } from '@ossy/event-store'
|
|
|
155
155
|
await Mongo.connect(process.env.DB_URL)
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
Recommended indexes
|
|
158
|
+
Recommended indexes (created at startup by `ensureEventStoreIndexes`):
|
|
159
159
|
|
|
160
160
|
```js
|
|
161
|
+
// eventstore
|
|
161
162
|
{ resourceId: 1, version: 1 } // stream queries
|
|
162
|
-
|
|
163
|
+
|
|
164
|
+
// aggregates
|
|
165
|
+
{ id: 1 } // snapshot get-by-id
|
|
166
|
+
{ type: 1, 'state.email': 1 } // user lookup (sparse)
|
|
167
|
+
{ 'state.belongsTo': 1, 'state.location': 1 } // folder list/search (sparse)
|
|
163
168
|
```
|
|
164
169
|
|
|
170
|
+
`{ type: 1, event: 1 }` on `eventstore` is still useful for projection replay / task triggers but is not created automatically.
|
|
171
|
+
|
|
165
172
|
---
|
|
166
173
|
|
|
167
174
|
## Testing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/event-store",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.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": "14548dfc2019e7258e8c75db527acbf66fe829bd"
|
|
41
41
|
}
|
package/src/ensure-indexes.js
CHANGED
|
@@ -9,6 +9,8 @@ let ensured = false
|
|
|
9
9
|
* Ensure MongoDB indexes required for aggregate/event-stream reads.
|
|
10
10
|
* Without `{ resourceId, version }` on `eventstore`, every `getResourceStream`
|
|
11
11
|
* call scans the full collection — sign-up and changestream rebuilds deadlock under load.
|
|
12
|
+
* Without `{ state.belongsTo, state.location }` on `aggregates`, folder list/search
|
|
13
|
+
* examines the whole collection, including audit snapshots that are not in any folder.
|
|
12
14
|
*/
|
|
13
15
|
export async function ensureEventStoreIndexes () {
|
|
14
16
|
if (ensured) return
|
|
@@ -29,6 +31,10 @@ export async function ensureEventStoreIndexes () {
|
|
|
29
31
|
{ type: 1, 'state.email': 1 },
|
|
30
32
|
{ name: 'type_state_email', background: true, sparse: true },
|
|
31
33
|
)
|
|
34
|
+
await aggregates.createIndex(
|
|
35
|
+
{ 'state.belongsTo': 1, 'state.location': 1 },
|
|
36
|
+
{ name: 'state_belongsTo_location', background: true, sparse: true },
|
|
37
|
+
)
|
|
32
38
|
})
|
|
33
39
|
|
|
34
40
|
ensured = true
|
|
@@ -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
|
+
})
|