@ossy/event-store 1.8.2 → 3.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/README.md +91 -82
- package/package.json +11 -4
- package/src/aggregate-rebuild.js +113 -51
- package/src/aggregate-rebuild.spec.js +54 -0
- package/src/aggregate-rebuild.task.js +13 -7
- package/src/aggregate.js +33 -130
- package/src/ensure-indexes.js +41 -0
- package/src/ensure-indexes.spec.js +9 -0
- package/src/event-store.js +70 -78
- package/src/index.js +5 -0
- package/src/projection-queries.js +17 -0
- package/src/projection-rebuild.js +136 -0
- package/src/push-invalidation.js +130 -0
- package/src/push-invalidation.spec.js +64 -0
- package/src/schema-stream.js +104 -0
package/README.md
CHANGED
|
@@ -1,46 +1,59 @@
|
|
|
1
1
|
# @ossy/event-store
|
|
2
2
|
|
|
3
|
-
Event sourcing primitives for the Ossy platform. Provides `Aggregate` (
|
|
3
|
+
Event sourcing primitives for the Ossy platform. Provides `Aggregate` (ADR 0008 entity streams), `EventStore` (resource event I/O), `ProjectionRebuild` / `getProjection` (derived read models), `PushInvalidation` (SSE cache invalidation), and `AggregateRebuild` (startup snapshot rebuilding) on top of MongoDB.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
| Export | Module | Purpose |
|
|
8
|
+
|--------|--------|---------|
|
|
9
|
+
| `Mongo` | `mongodb.js` | Connect to MongoDB |
|
|
10
|
+
| `Aggregate` | `aggregate.js` | Entity stream facade (delegates to `SchemaStream`) |
|
|
11
|
+
| `SchemaStream` | `schema-stream.js` | Low-level ADR 0008 entity stream I/O |
|
|
12
|
+
| `EventStore` | `event-store.js` | Append and query resource events |
|
|
13
|
+
| `AggregateRebuild` | `aggregate-rebuild.js` | Startup snapshot rebuild for entities and resources |
|
|
14
|
+
| `ProjectionRebuild` | `projection-rebuild.js` | Incremental and full projection rebuild |
|
|
15
|
+
| `getProjection` | `projection-queries.js` | Read a projection snapshot by scope |
|
|
16
|
+
| `PushInvalidation` | `push-invalidation.js` | Workspace-scoped SSE fan-out |
|
|
17
|
+
| `buildInvalidationKeys`, `buildPushMessage` | `push-invalidation.js` | Derive client cache keys from events |
|
|
4
18
|
|
|
5
19
|
## Core concepts
|
|
6
20
|
|
|
7
21
|
The event store keeps two MongoDB collections:
|
|
8
22
|
|
|
9
|
-
- **`eventstore`** — every event ever appended. Immutable.
|
|
10
|
-
- **`aggregates`** — denormalized state snapshots
|
|
23
|
+
- **`eventstore`** — every resource/entity event ever appended. Immutable. ADR 0008 documents use `type` (schema id), `resourceId`, `event` (lifecycle name), `version`, `payload`, `created`, and `createdBy`.
|
|
24
|
+
- **`aggregates`** — denormalized state snapshots: entity/resource folds keyed by `id`, and projection snapshots keyed by `{ id: scopeId, type: projectionId, kind: 'projection' }`. Rebuilt at startup and updated on the changestream.
|
|
11
25
|
|
|
12
26
|
---
|
|
13
27
|
|
|
14
28
|
## `Aggregate`
|
|
15
29
|
|
|
16
|
-
|
|
30
|
+
Facade over `SchemaStream` for entity aggregates that declare `static SchemaId`. All methods return Promises.
|
|
17
31
|
|
|
18
32
|
### Reading the current state
|
|
19
33
|
|
|
20
34
|
```js
|
|
21
35
|
import { Aggregate } from '@ossy/event-store'
|
|
22
|
-
import {
|
|
36
|
+
import { User } from '@ossy/users/server'
|
|
23
37
|
|
|
24
|
-
|
|
25
|
-
const state = await Aggregate.Of(Order, orderId).then(Aggregate.View())
|
|
38
|
+
const state = await Aggregate.Of(User, userId).then(Aggregate.View())
|
|
26
39
|
```
|
|
27
40
|
|
|
28
|
-
### Creating a new
|
|
41
|
+
### Creating a new entity
|
|
29
42
|
|
|
30
|
-
Pass
|
|
43
|
+
Pass a creation event as the identifier. The event is appended and the stream is returned.
|
|
31
44
|
|
|
32
45
|
```js
|
|
33
|
-
const
|
|
34
|
-
const
|
|
46
|
+
const createdEvent = UsersEvents.Created({ email, firstName, lastName })
|
|
47
|
+
const userView = await Aggregate.Of(User, createdEvent).then(Aggregate.View())
|
|
35
48
|
```
|
|
36
49
|
|
|
37
|
-
|
|
50
|
+
`resourceId` in the event is the entity id; if absent, the event factory generates one.
|
|
38
51
|
|
|
39
|
-
### Appending
|
|
52
|
+
### Appending to an existing entity
|
|
40
53
|
|
|
41
54
|
```js
|
|
42
|
-
await Aggregate.Of(
|
|
43
|
-
.then(Aggregate.Add({
|
|
55
|
+
await Aggregate.Of(User, userId)
|
|
56
|
+
.then(Aggregate.Add(UsersEvents.NameUpdated({ firstName, lastName, createdBy: userId })))
|
|
44
57
|
.then(Aggregate.Save())
|
|
45
58
|
```
|
|
46
59
|
|
|
@@ -48,117 +61,113 @@ await Aggregate.Of(Order, orderId)
|
|
|
48
61
|
|
|
49
62
|
| Method | Signature | Description |
|
|
50
63
|
|---|---|---|
|
|
51
|
-
| `Aggregate.Of(Root, id)` | `(class, string) => Promise<
|
|
52
|
-
| `Aggregate.Of(Root, event)` | `(class, object) => Promise<
|
|
53
|
-
| `Aggregate.Add(event)` | `(event) => (
|
|
54
|
-
| `Aggregate.Save()` | `() => (
|
|
55
|
-
| `Aggregate.View(fn?)` | `(fn?) => (
|
|
56
|
-
| `Aggregate.Validate(fn)` | `(fn) => (
|
|
57
|
-
| `Aggregate.Find(id)` | `(string) => Promise<object \| null>` |
|
|
64
|
+
| `Aggregate.Of(Root, id)` | `(class, string) => Promise<Stream>` | Load an entity stream by `resourceId`. |
|
|
65
|
+
| `Aggregate.Of(Root, event)` | `(class, object) => Promise<Stream>` | Create a new entity by appending the first event. |
|
|
66
|
+
| `Aggregate.Add(event)` | `(event) => (stream) => Promise<Stream>` | Append an event. Chainable. |
|
|
67
|
+
| `Aggregate.Save()` | `() => (stream) => Promise<void>` | Persist the folded snapshot to `aggregates`. |
|
|
68
|
+
| `Aggregate.View(fn?)` | `(fn?) => (stream) => state` | Fold events through the aggregate `View` reducer. |
|
|
69
|
+
| `Aggregate.Validate(fn)` | `(fn) => (stream) => Promise<Stream>` | Run validation against `(events, savedState)`. |
|
|
70
|
+
| `Aggregate.Find(id)` | `(string) => Promise<object \| null>` | Find a snapshot document in `aggregates` by id. |
|
|
58
71
|
| `Aggregate.Collection` | `Collection` | Direct access to the MongoDB `aggregates` collection. |
|
|
59
72
|
|
|
73
|
+
Entity aggregate classes require `static SchemaId` (ADR 0008). Resource documents use `@ossy/resources` `commitResource` / `mutateResource` instead.
|
|
74
|
+
|
|
60
75
|
---
|
|
61
76
|
|
|
62
|
-
##
|
|
77
|
+
## `EventStore`
|
|
63
78
|
|
|
64
|
-
|
|
79
|
+
Low-level access to the `eventstore` collection.
|
|
65
80
|
|
|
66
81
|
```js
|
|
67
|
-
|
|
68
|
-
export class Order {
|
|
69
|
-
static AggregateType = 'Order'
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Folds an array of events into the current state.
|
|
73
|
-
* @param {object[]} events - Event documents in ascending version order.
|
|
74
|
-
* @param {object} savedState - Last saved snapshot (can be empty object).
|
|
75
|
-
* @returns {object}
|
|
76
|
-
*/
|
|
77
|
-
static View(events, savedState = {}) {
|
|
78
|
-
return events.reduce((state, event) => {
|
|
79
|
-
switch (event.type) {
|
|
80
|
-
case 'OrderPlaced':
|
|
81
|
-
return { ...state, id: event.aggregateId, status: 'pending', ...event.payload }
|
|
82
|
-
case 'OrderShipped':
|
|
83
|
-
return { ...state, status: 'shipped', shippedAt: event.created }
|
|
84
|
-
case 'OrderCancelled':
|
|
85
|
-
return { ...state, status: 'cancelled' }
|
|
86
|
-
default:
|
|
87
|
-
return state
|
|
88
|
-
}
|
|
89
|
-
}, savedState)
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Required exports for the *.aggregate.js primitive
|
|
94
|
-
export { Order as Aggregate }
|
|
95
|
-
export const id = 'Order' // must equal Order.AggregateType
|
|
82
|
+
import { EventStore } from '@ossy/event-store'
|
|
96
83
|
```
|
|
97
84
|
|
|
98
|
-
|
|
85
|
+
| Method | Signature | Description |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `EventStore.AppendResourceEvent(event)` | `(object) => Promise<object>` | Insert an ADR 0008 event document. |
|
|
88
|
+
| `EventStore.GetResourceStream({ resourceId, fromVersion? })` | `=> Promise<object[]>` | Events for one `resourceId`, optionally after a version. |
|
|
89
|
+
| `EventStore.GetResourceStreams()` | `=> Promise<{ resourceId, type }[]>` | All known `(resourceId, schemaId)` pairs. |
|
|
90
|
+
| `EventStore.FindEvent(query)` | `(MongoQuery) => Promise<object>` | Find a single event. Rejects when not found. |
|
|
91
|
+
| `EventStore.FindEvents(query)` | `(MongoQuery) => Promise<object[]>` | Find multiple events. Rejects when none found. |
|
|
92
|
+
| `EventStore.Aggregate(pipeline)` | `(Pipeline) => Promise<object[]>` | Run a MongoDB aggregation pipeline. |
|
|
93
|
+
| `EventStore.Collection` | `Collection` | Direct access to the `eventstore` collection. |
|
|
99
94
|
|
|
100
95
|
---
|
|
101
96
|
|
|
102
|
-
##
|
|
97
|
+
## Projections
|
|
103
98
|
|
|
104
|
-
|
|
99
|
+
Projection aggregates (`kind: 'projection'`) fold resource events into scope-keyed read models (e.g. workspace booking list).
|
|
105
100
|
|
|
106
101
|
```js
|
|
107
|
-
import {
|
|
102
|
+
import { getProjection } from '@ossy/event-store'
|
|
103
|
+
|
|
104
|
+
const list = await getProjection(workspaceId, '@ossy/booking/data/booking-list')
|
|
108
105
|
```
|
|
109
106
|
|
|
110
|
-
|
|
|
111
|
-
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
107
|
+
| API | Description |
|
|
108
|
+
|-----|-------------|
|
|
109
|
+
| `ProjectionRebuild.registerProjection({ id, Aggregate })` | Register a projection class (also called via `AggregateRebuild.registerAggregate` when `kind === 'projection'`) |
|
|
110
|
+
| `ProjectionRebuild.dispatch(event)` | Apply one changestream event to matching projections |
|
|
111
|
+
| `ProjectionRebuild.rebuildAll()` | Replay source events at startup (called from `AggregateRebuild.BuildAndSaveAll()`) |
|
|
112
|
+
|
|
113
|
+
Projection classes declare `static sources`, `static scopeFromEvent(event)`, `static Apply(event, state)`, and optionally `static cacheKeys(scopeId, event)`.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Push invalidation (ADR 0008 §9)
|
|
118
|
+
|
|
119
|
+
After changestream rebuild, `PushInvalidation.publish(event)` fans out SSE messages to workspace subscribers.
|
|
120
|
+
|
|
121
|
+
- **Server:** `GET /events` (workspace-scoped via `workspaceId` header or user settings cookie)
|
|
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
|
+
- **Keys:** `resource:{id}`, `location:{path}`, `projection:{id}:{scopeId}`, `action:{actionId}`
|
|
124
|
+
|
|
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.
|
|
119
126
|
|
|
120
127
|
---
|
|
121
128
|
|
|
122
129
|
## `AggregateRebuild`
|
|
123
130
|
|
|
124
|
-
Rebuilds
|
|
131
|
+
Rebuilds entity/resource snapshots from scratch at startup. Called automatically by `@ossy/platform`.
|
|
125
132
|
|
|
126
133
|
```js
|
|
127
134
|
import { AggregateRebuild } from '@ossy/event-store'
|
|
128
135
|
|
|
129
|
-
|
|
130
|
-
AggregateRebuild.registerAggregate({ id: 'Order', Aggregate: Order })
|
|
131
|
-
|
|
132
|
-
// Rebuild and save every known aggregate of every registered type
|
|
136
|
+
AggregateRebuild.registerAggregate({ id: 'User', Aggregate: User })
|
|
133
137
|
await AggregateRebuild.BuildAndSaveAll()
|
|
134
|
-
|
|
135
|
-
// Rebuild a single aggregate
|
|
136
|
-
await AggregateRebuild.BuildAndSave('Order', orderId)
|
|
137
138
|
```
|
|
138
139
|
|
|
140
|
+
`registerAggregate` routes projection modules (`Aggregate.kind === 'projection'`) to `ProjectionRebuild`. Entity classes are indexed by `Aggregate.SchemaId` for resource rebuilds.
|
|
141
|
+
|
|
139
142
|
---
|
|
140
143
|
|
|
141
144
|
## `*.aggregate.js` primitive
|
|
142
145
|
|
|
143
|
-
The platform auto-discovers aggregate files from installed packages. See [PRIMITIVES.md](../platform/PRIMITIVES.md#aggregate)
|
|
146
|
+
The platform auto-discovers aggregate files from installed packages. Entity aggregates export `{ id, Aggregate }` with `Aggregate.SchemaId`; projection aggregates set `Aggregate.kind = 'projection'`. See [PRIMITIVES.md](../platform/PRIMITIVES.md#aggregate).
|
|
144
147
|
|
|
145
148
|
---
|
|
146
149
|
|
|
147
150
|
## MongoDB setup
|
|
148
151
|
|
|
149
|
-
Connect to MongoDB before using any `Aggregate` or `EventStore` methods:
|
|
150
|
-
|
|
151
152
|
```js
|
|
152
153
|
import { Mongo } from '@ossy/event-store'
|
|
153
154
|
|
|
154
155
|
await Mongo.connect(process.env.DB_URL)
|
|
155
156
|
```
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
Indexes recommended for the `eventstore` collection:
|
|
158
|
+
Recommended indexes for `eventstore`:
|
|
160
159
|
|
|
161
160
|
```js
|
|
162
|
-
{
|
|
163
|
-
{
|
|
161
|
+
{ resourceId: 1, version: 1 } // stream queries
|
|
162
|
+
{ type: 1, event: 1 } // projection replay / task triggers
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Testing
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm test -w @ossy/event-store
|
|
164
171
|
```
|
|
172
|
+
|
|
173
|
+
Runs unit tests for push invalidation key derivation (`push-invalidation.spec.js`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/event-store",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Ossy Event Store — Aggregate, EventStore, and MongoDB client",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,16 +19,23 @@
|
|
|
19
19
|
"access": "public",
|
|
20
20
|
"registry": "https://registry.npmjs.org"
|
|
21
21
|
},
|
|
22
|
-
"
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
|
|
24
|
+
},
|
|
23
25
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
24
26
|
"license": "MIT",
|
|
25
27
|
"dependencies": {
|
|
26
|
-
"@ossy/
|
|
28
|
+
"@ossy/fold": "^3.0.1",
|
|
29
|
+
"@ossy/observability": "^3.0.1",
|
|
27
30
|
"mongodb": "^7.2.0",
|
|
28
31
|
"nanoid": "^5.1.11"
|
|
29
32
|
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@jest/globals": "^30.2.0",
|
|
35
|
+
"jest": "^30.2.0"
|
|
36
|
+
},
|
|
30
37
|
"files": [
|
|
31
38
|
"src"
|
|
32
39
|
],
|
|
33
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "4a70ec216448680d2fddc57b2a8a0077489720ae"
|
|
34
41
|
}
|
package/src/aggregate-rebuild.js
CHANGED
|
@@ -1,65 +1,127 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { EventStore, getResourceStream } from './event-store.js'
|
|
2
|
+
import { SchemaStream } from './schema-stream.js'
|
|
3
|
+
import { ProjectionRebuild } from './projection-rebuild.js'
|
|
4
|
+
import { withMongoReconnect } from './mongodb.js'
|
|
5
|
+
import { createDefaultReducer } from '@ossy/fold'
|
|
3
6
|
import { createLogger } from '@ossy/observability'
|
|
4
7
|
|
|
5
8
|
const log = createLogger('event-store')
|
|
6
9
|
|
|
7
10
|
export class AggregateRebuild {
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
12
|
+
/** @type {Record<string, Function>} */
|
|
13
|
+
static _aggregateMap = {}
|
|
14
|
+
|
|
15
|
+
/** @type {Record<string, Function>} */
|
|
16
|
+
static _schemaMap = {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ id: string, Aggregate: Function }} mod
|
|
20
|
+
*/
|
|
21
|
+
static registerAggregate (mod) {
|
|
22
|
+
const { id, Aggregate: AggregateClass } = mod
|
|
23
|
+
if (typeof AggregateClass !== 'function' || !id) {
|
|
24
|
+
log.warn('[AggregateRebuild] registerAggregate: invalid module — expected { id, Aggregate }')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
AggregateRebuild._aggregateMap[id] = AggregateClass
|
|
28
|
+
if (AggregateClass.SchemaId) {
|
|
29
|
+
AggregateRebuild._schemaMap[AggregateClass.SchemaId] = AggregateClass
|
|
30
|
+
}
|
|
31
|
+
if (AggregateClass.kind === 'projection') {
|
|
32
|
+
ProjectionRebuild.registerProjection(mod)
|
|
33
|
+
return
|
|
27
34
|
}
|
|
35
|
+
log.debug(`[AggregateRebuild] Registered aggregate: ${id}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
|
47
|
+
const AggregateRoot = schemaId ? AggregateRebuild._schemaMap[schemaId] : null
|
|
48
|
+
const snapshot = await SchemaStream.Find(resourceId)
|
|
49
|
+
const fromVersion = snapshot?.version ?? 0
|
|
50
|
+
|
|
51
|
+
const events = await AggregateRebuild._loadTailEvents({
|
|
52
|
+
resourceId,
|
|
53
|
+
fromVersion,
|
|
54
|
+
triggerEvent,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (!events.length) return
|
|
28
58
|
|
|
29
|
-
|
|
30
|
-
|
|
59
|
+
const state = AggregateRoot
|
|
60
|
+
? AggregateRoot.View(events, snapshot?.state)
|
|
61
|
+
: createDefaultReducer()(events, snapshot?.state)
|
|
31
62
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return
|
|
35
|
-
}
|
|
63
|
+
const type = schemaId ?? state?.type ?? events[0]?.type ?? snapshot?.type
|
|
64
|
+
const version = events[events.length - 1].version
|
|
36
65
|
|
|
37
|
-
|
|
38
|
-
|
|
66
|
+
return withMongoReconnect(() =>
|
|
67
|
+
SchemaStream.Collection.updateOne(
|
|
68
|
+
{ id: resourceId },
|
|
69
|
+
{ $set: { id: resourceId, version, type, state } },
|
|
70
|
+
{ upsert: true },
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
}
|
|
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]
|
|
39
85
|
}
|
|
40
86
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return EventStore.GetEventStreams()
|
|
52
|
-
.then(streams => Promise.allSettled(
|
|
53
|
-
streams
|
|
54
|
-
.filter(({ aggregateType }) => knownTypes.has(aggregateType))
|
|
55
|
-
.map(({ aggregateType, aggregateId }) => AggregateRebuild.BuildAndSave(aggregateType, aggregateId))
|
|
56
|
-
))
|
|
57
|
-
.then(results => {
|
|
58
|
-
const failed = results.filter(result => result.status === 'rejected')
|
|
59
|
-
const success = results.filter(result => result.status === 'fulfilled')
|
|
60
|
-
log.info('[AggregateRebuild][BuildAndSaveAll] Finished building aggregates')
|
|
61
|
-
log.info(`[AggregateRebuild][BuildAndSaveAll] Failed: ${failed.length}`)
|
|
62
|
-
log.info(`[AggregateRebuild][BuildAndSaveAll] Success: ${success.length}`)
|
|
63
|
-
})
|
|
87
|
+
return getResourceStream({ resourceId, fromVersion })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static async BuildAndSaveAll () {
|
|
91
|
+
log.debug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
|
|
92
|
+
|
|
93
|
+
if (Object.keys(AggregateRebuild._schemaMap).length === 0) {
|
|
94
|
+
log.warn('[AggregateRebuild][BuildAndSaveAll] No schema aggregates registered — skipping rebuild')
|
|
95
|
+
return
|
|
64
96
|
}
|
|
97
|
+
|
|
98
|
+
return EventStore.GetResourceStreams()
|
|
99
|
+
.then(streams => Promise.allSettled(
|
|
100
|
+
streams.map(({ resourceId, type }) => AggregateRebuild.BuildAndSaveResource(resourceId, type)),
|
|
101
|
+
))
|
|
102
|
+
.then(async results => {
|
|
103
|
+
await ProjectionRebuild.rebuildAll()
|
|
104
|
+
const failed = results.filter(r => r.status === 'rejected')
|
|
105
|
+
const success = results.filter(r => r.status === 'fulfilled')
|
|
106
|
+
log.info('[AggregateRebuild][BuildAndSaveAll] Finished building aggregates')
|
|
107
|
+
log.info(`[AggregateRebuild][BuildAndSaveAll] Failed: ${failed.length}`)
|
|
108
|
+
log.info(`[AggregateRebuild][BuildAndSaveAll] Success: ${success.length}`)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
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
|
|
65
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
|
+
})
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { AggregateRebuild } from '
|
|
1
|
+
import { AggregateRebuild } from './aggregate-rebuild.js'
|
|
2
|
+
|
|
3
|
+
/** Platform audit envelopes have no entity aggregate snapshot — handled inline on the changestream. */
|
|
4
|
+
const PLATFORM_AUDIT_TYPES = [
|
|
5
|
+
'@ossy/platform/schema/task-run',
|
|
6
|
+
'@ossy/platform/schema/action-invocation',
|
|
7
|
+
'@ossy/platform/schema/meter-event',
|
|
8
|
+
]
|
|
2
9
|
|
|
3
10
|
export const metadata = {
|
|
4
|
-
id: 'aggregate-rebuild',
|
|
5
|
-
|
|
6
|
-
// skips any aggregateType not registered in the manifest, so only events
|
|
7
|
-
// for known aggregate types result in a rebuild.
|
|
8
|
-
triggers: [{}]
|
|
11
|
+
id: '@ossy/event-store/tasks/aggregate-rebuild',
|
|
12
|
+
triggers: [{ excludeTypes: PLATFORM_AUDIT_TYPES }],
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
export async function run ({ event }) {
|
|
12
|
-
|
|
16
|
+
if (!event?.resourceId) return
|
|
17
|
+
|
|
18
|
+
await AggregateRebuild.BuildAndSaveResource(event.resourceId, event.type, { event })
|
|
13
19
|
}
|