@ossy/event-store 1.8.3 → 3.0.2
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 +1 -1
- package/package.json +4 -4
- package/src/aggregate-rebuild.js +50 -5
- package/src/aggregate-rebuild.spec.js +54 -0
- package/src/aggregate-rebuild.task.js +1 -1
- package/src/ensure-indexes.js +41 -0
- package/src/ensure-indexes.spec.js +9 -0
- package/src/index.js +1 -0
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ Projection classes declare `static sources`, `static scopeFromEvent(event)`, `st
|
|
|
119
119
|
After changestream rebuild, `PushInvalidation.publish(event)` fans out SSE messages to workspace subscribers.
|
|
120
120
|
|
|
121
121
|
- **Server:** `GET /events` (workspace-scoped via `workspaceId` header or user settings cookie)
|
|
122
|
-
- **Client:** `sdk.subscribePush({ onMessage })` — `
|
|
122
|
+
- **Client:** `sdk.subscribePush({ onMessage })` — opt in via `PushInvalidationSubscriber` or `enablePushInvalidation` in app config (off by default; see [@ossy/sdk-react README](../sdk-react/README.md#push-invalidation))
|
|
123
123
|
- **Keys:** `resource:{id}`, `location:{path}`, `projection:{id}:{scopeId}`, `action:{actionId}`
|
|
124
124
|
|
|
125
125
|
Use `buildInvalidationKeys(event)` to derive keys server-side; `buildPushMessage(event)` wraps them for SSE payloads. Projection aggregates may define `static cacheKeys(scopeId, event)` for bespoke list-action keys.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/event-store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "Ossy Event Store — Aggregate, EventStore, and MongoDB client",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@ossy/fold": "^
|
|
29
|
-
"@ossy/observability": "^
|
|
28
|
+
"@ossy/fold": "^3.0.2",
|
|
29
|
+
"@ossy/observability": "^3.0.2",
|
|
30
30
|
"mongodb": "^7.2.0",
|
|
31
31
|
"nanoid": "^5.1.11"
|
|
32
32
|
},
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"src"
|
|
39
39
|
],
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "f0a8af3c370bbaffe4f7388c14f9345a5c6c56e5"
|
|
41
41
|
}
|
package/src/aggregate-rebuild.js
CHANGED
|
@@ -35,19 +35,33 @@ export class AggregateRebuild {
|
|
|
35
35
|
log.debug(`[AggregateRebuild] Registered aggregate: ${id}`)
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Incrementally update an entity snapshot — same semantics as `SchemaStream.Of` + `Save`.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} resourceId
|
|
42
|
+
* @param {string} [schemaId]
|
|
43
|
+
* @param {{ event?: object }} [options] changestream trigger event (avoids re-query when next in sequence)
|
|
44
|
+
*/
|
|
45
|
+
static async BuildAndSaveResource (resourceId, schemaId, options = {}) {
|
|
46
|
+
const { event: triggerEvent } = options
|
|
39
47
|
const AggregateRoot = schemaId ? AggregateRebuild._schemaMap[schemaId] : null
|
|
40
48
|
const snapshot = await SchemaStream.Find(resourceId)
|
|
41
|
-
const
|
|
49
|
+
const fromVersion = snapshot?.version ?? 0
|
|
50
|
+
|
|
51
|
+
const events = await AggregateRebuild._loadTailEvents({
|
|
52
|
+
resourceId,
|
|
53
|
+
fromVersion,
|
|
54
|
+
triggerEvent,
|
|
55
|
+
})
|
|
42
56
|
|
|
43
|
-
if (!events.length
|
|
57
|
+
if (!events.length) return
|
|
44
58
|
|
|
45
59
|
const state = AggregateRoot
|
|
46
60
|
? AggregateRoot.View(events, snapshot?.state)
|
|
47
61
|
: createDefaultReducer()(events, snapshot?.state)
|
|
48
62
|
|
|
49
|
-
const type = schemaId ?? state
|
|
50
|
-
const version = events[events.length - 1]
|
|
63
|
+
const type = schemaId ?? state?.type ?? events[0]?.type ?? snapshot?.type
|
|
64
|
+
const version = events[events.length - 1].version
|
|
51
65
|
|
|
52
66
|
return withMongoReconnect(() =>
|
|
53
67
|
SchemaStream.Collection.updateOne(
|
|
@@ -58,6 +72,21 @@ export class AggregateRebuild {
|
|
|
58
72
|
)
|
|
59
73
|
}
|
|
60
74
|
|
|
75
|
+
/**
|
|
76
|
+
* @param {{ resourceId: string, fromVersion: number, triggerEvent?: object }} opts
|
|
77
|
+
* @returns {Promise<object[]>}
|
|
78
|
+
*/
|
|
79
|
+
static async _loadTailEvents ({ resourceId, fromVersion, triggerEvent }) {
|
|
80
|
+
if (
|
|
81
|
+
triggerEvent?.resourceId === resourceId &&
|
|
82
|
+
triggerEvent.version === fromVersion + 1
|
|
83
|
+
) {
|
|
84
|
+
return [triggerEvent]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return getResourceStream({ resourceId, fromVersion })
|
|
88
|
+
}
|
|
89
|
+
|
|
61
90
|
static async BuildAndSaveAll () {
|
|
62
91
|
log.debug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
|
|
63
92
|
|
|
@@ -80,3 +109,19 @@ export class AggregateRebuild {
|
|
|
80
109
|
})
|
|
81
110
|
}
|
|
82
111
|
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pure helper — pick tail events for incremental rebuild (exported for tests).
|
|
115
|
+
*
|
|
116
|
+
* @param {{ fromVersion: number, triggerEvent?: object, resourceId: string, fetchedTail: object[] }} opts
|
|
117
|
+
* @returns {object[]}
|
|
118
|
+
*/
|
|
119
|
+
export function selectTailEventsForRebuild ({ resourceId, fromVersion, triggerEvent, fetchedTail }) {
|
|
120
|
+
if (
|
|
121
|
+
triggerEvent?.resourceId === resourceId &&
|
|
122
|
+
triggerEvent.version === fromVersion + 1
|
|
123
|
+
) {
|
|
124
|
+
return [triggerEvent]
|
|
125
|
+
}
|
|
126
|
+
return fetchedTail
|
|
127
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { selectTailEventsForRebuild } from './aggregate-rebuild.js'
|
|
3
|
+
|
|
4
|
+
describe('selectTailEventsForRebuild', () => {
|
|
5
|
+
const resourceId = 'res_1'
|
|
6
|
+
|
|
7
|
+
it('uses the changestream trigger event when it is the next version', () => {
|
|
8
|
+
const triggerEvent = { resourceId, version: 3, event: 'Updated' }
|
|
9
|
+
const result = selectTailEventsForRebuild({
|
|
10
|
+
resourceId,
|
|
11
|
+
fromVersion: 2,
|
|
12
|
+
triggerEvent,
|
|
13
|
+
fetchedTail: [{ resourceId, version: 3 }],
|
|
14
|
+
})
|
|
15
|
+
expect(result).toEqual([triggerEvent])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('uses fetched tail when trigger is not the immediate next version', () => {
|
|
19
|
+
const fetchedTail = [
|
|
20
|
+
{ resourceId, version: 3 },
|
|
21
|
+
{ resourceId, version: 4 },
|
|
22
|
+
]
|
|
23
|
+
const result = selectTailEventsForRebuild({
|
|
24
|
+
resourceId,
|
|
25
|
+
fromVersion: 2,
|
|
26
|
+
triggerEvent: { resourceId, version: 4, event: 'Updated' },
|
|
27
|
+
fetchedTail,
|
|
28
|
+
})
|
|
29
|
+
expect(result).toEqual(fetchedTail)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('uses fetched tail when no trigger event is provided', () => {
|
|
33
|
+
const fetchedTail = [{ resourceId, version: 1 }]
|
|
34
|
+
const result = selectTailEventsForRebuild({
|
|
35
|
+
resourceId,
|
|
36
|
+
fromVersion: 0,
|
|
37
|
+
fetchedTail,
|
|
38
|
+
})
|
|
39
|
+
expect(result).toEqual(fetchedTail)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('incremental rebuild semantics', () => {
|
|
44
|
+
it('folds only tail events onto snapshot state', () => {
|
|
45
|
+
const View = (events, state = {}) =>
|
|
46
|
+
events.reduce((acc, e) => ({ ...acc, count: (acc.count ?? 0) + 1, last: e.event }), state)
|
|
47
|
+
|
|
48
|
+
const snapshot = { version: 2, state: { count: 2, last: 'Created' } }
|
|
49
|
+
const tail = [{ version: 3, event: 'Updated' }]
|
|
50
|
+
const next = View(tail, snapshot.state)
|
|
51
|
+
|
|
52
|
+
expect(next).toEqual({ count: 3, last: 'Updated' })
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -15,5 +15,5 @@ export const metadata = {
|
|
|
15
15
|
export async function run ({ event }) {
|
|
16
16
|
if (!event?.resourceId) return
|
|
17
17
|
|
|
18
|
-
await AggregateRebuild.BuildAndSaveResource(event.resourceId, event.type)
|
|
18
|
+
await AggregateRebuild.BuildAndSaveResource(event.resourceId, event.type, { event })
|
|
19
19
|
}
|
|
@@ -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
|
+
})
|
package/src/index.js
CHANGED
|
@@ -6,3 +6,4 @@ export * from './aggregate-rebuild.js'
|
|
|
6
6
|
export * from './projection-rebuild.js'
|
|
7
7
|
export { getProjection } from './projection-queries.js'
|
|
8
8
|
export { PushInvalidation, buildInvalidationKeys, buildPushMessage } from './push-invalidation.js'
|
|
9
|
+
export { ensureEventStoreIndexes, resetEnsureIndexesForTests } from './ensure-indexes.js'
|