@ossy/event-store 3.7.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/event-store",
3
- "version": "3.7.0",
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": "622d97535abad08415963405ace2f5aaaecd7ca4"
40
+ "gitHead": "14548dfc2019e7258e8c75db527acbf66fe829bd"
41
41
  }
@@ -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 = typeof ProjectionClass.scopeFromEvent === 'function'
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 = new Map()
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
+ })