@ossy/event-store 1.8.1 → 1.8.3
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 +65 -48
- package/src/aggregate-rebuild.task.js +13 -7
- package/src/aggregate.js +33 -130
- package/src/event-store.js +70 -78
- package/src/index.js +4 -0
- package/src/mongodb.js +19 -4
- 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 })` — `WorkspaceProvider` invalidates read-cache keys automatically
|
|
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": "1.8.
|
|
3
|
+
"version": "1.8.3",
|
|
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": "^1.0.1",
|
|
29
|
+
"@ossy/observability": "^1.8.3",
|
|
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": "a0d89185a17f8de8ce328c3a648c108ff1d61d8f"
|
|
34
41
|
}
|
package/src/aggregate-rebuild.js
CHANGED
|
@@ -1,65 +1,82 @@
|
|
|
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
|
-
|
|
12
|
+
/** @type {Record<string, Function>} */
|
|
13
|
+
static _aggregateMap = {}
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
static async BuildAndSaveResource (resourceId, schemaId) {
|
|
39
|
+
const AggregateRoot = schemaId ? AggregateRebuild._schemaMap[schemaId] : null
|
|
40
|
+
const snapshot = await SchemaStream.Find(resourceId)
|
|
41
|
+
const events = await getResourceStream({ resourceId, fromVersion: 0 })
|
|
31
42
|
|
|
32
|
-
|
|
33
|
-
log.debug(`[AggregateRebuild][BuildAndSave] Skipping unregistered aggregate type: ${aggregateType}`)
|
|
34
|
-
return
|
|
35
|
-
}
|
|
43
|
+
if (!events.length && !snapshot) return
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
const state = AggregateRoot
|
|
46
|
+
? AggregateRoot.View(events, snapshot?.state)
|
|
47
|
+
: createDefaultReducer()(events, snapshot?.state)
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
const type = schemaId ?? state.type ?? events[0]?.type
|
|
50
|
+
const version = events[events.length - 1]?.version ?? snapshot?.version ?? 0
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
return withMongoReconnect(() =>
|
|
53
|
+
SchemaStream.Collection.updateOne(
|
|
54
|
+
{ id: resourceId },
|
|
55
|
+
{ $set: { id: resourceId, version, type, state } },
|
|
56
|
+
{ upsert: true },
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
45
60
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return
|
|
49
|
-
}
|
|
61
|
+
static async BuildAndSaveAll () {
|
|
62
|
+
log.debug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
})
|
|
64
|
+
if (Object.keys(AggregateRebuild._schemaMap).length === 0) {
|
|
65
|
+
log.warn('[AggregateRebuild][BuildAndSaveAll] No schema aggregates registered — skipping rebuild')
|
|
66
|
+
return
|
|
64
67
|
}
|
|
68
|
+
|
|
69
|
+
return EventStore.GetResourceStreams()
|
|
70
|
+
.then(streams => Promise.allSettled(
|
|
71
|
+
streams.map(({ resourceId, type }) => AggregateRebuild.BuildAndSaveResource(resourceId, type)),
|
|
72
|
+
))
|
|
73
|
+
.then(async results => {
|
|
74
|
+
await ProjectionRebuild.rebuildAll()
|
|
75
|
+
const failed = results.filter(r => r.status === 'rejected')
|
|
76
|
+
const success = results.filter(r => r.status === 'fulfilled')
|
|
77
|
+
log.info('[AggregateRebuild][BuildAndSaveAll] Finished building aggregates')
|
|
78
|
+
log.info(`[AggregateRebuild][BuildAndSaveAll] Failed: ${failed.length}`)
|
|
79
|
+
log.info(`[AggregateRebuild][BuildAndSaveAll] Success: ${success.length}`)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
65
82
|
}
|
|
@@ -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)
|
|
13
19
|
}
|
package/src/aggregate.js
CHANGED
|
@@ -1,157 +1,60 @@
|
|
|
1
1
|
import { nanoid } from 'nanoid'
|
|
2
|
-
import {
|
|
3
|
-
import { EventStore } from './event-store.js'
|
|
2
|
+
import { SchemaStream } from './schema-stream.js'
|
|
4
3
|
import { createLogger } from '@ossy/observability'
|
|
5
4
|
|
|
6
5
|
const log = createLogger('event-store')
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
*
|
|
10
|
-
* Use this class if you want to create a new stream, add events to an existing stream or view streams.
|
|
11
|
-
* @class
|
|
8
|
+
* ADR 0008 entity stream facade — all aggregates require `SchemaId`.
|
|
12
9
|
*/
|
|
13
10
|
export class Aggregate {
|
|
14
11
|
|
|
15
|
-
static get Collection() {
|
|
16
|
-
return
|
|
12
|
+
static get Collection () {
|
|
13
|
+
return SchemaStream.Collection
|
|
17
14
|
}
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* @return {Aggregate} - Instance of Aggregate
|
|
25
|
-
*
|
|
26
|
-
* @example
|
|
27
|
-
* Aggregate.Of(User, userId)
|
|
28
|
-
* Aggregate.Of(Workspace, workspaceId)
|
|
29
|
-
*/
|
|
30
|
-
static Of(AggregateRoot, identifier) {
|
|
31
|
-
|
|
32
|
-
log.info(`[Aggregate][Of()][${AggregateRoot?.AggregateType}] Creating aggregate root`)
|
|
33
|
-
|
|
34
|
-
if (typeof identifier === 'string') {
|
|
35
|
-
|
|
36
|
-
return Aggregate.Find(identifier)
|
|
37
|
-
.then(aggregate => {
|
|
38
|
-
return EventStore.GetEventStream({ aggregateId: identifier, fromVersion: aggregate?.version })
|
|
39
|
-
.then(events => new Aggregate(AggregateRoot, identifier, events, aggregate))
|
|
40
|
-
})
|
|
16
|
+
static Of (AggregateRoot, identifier) {
|
|
17
|
+
if (!AggregateRoot?.SchemaId) {
|
|
18
|
+
return Promise.reject(new Error(
|
|
19
|
+
`[Aggregate] ${AggregateRoot?.AggregateType ?? 'Unknown'} missing SchemaId — use an ADR 0008 entity aggregate`,
|
|
20
|
+
))
|
|
41
21
|
}
|
|
42
22
|
|
|
43
|
-
if (typeof identifier === 'object') {
|
|
44
|
-
log.info(
|
|
45
|
-
|
|
23
|
+
if (typeof identifier === 'object' && identifier !== null) {
|
|
24
|
+
log.info(`[Aggregate][Of] Creating ${AggregateRoot.SchemaId}`)
|
|
25
|
+
const resourceId = identifier.resourceId ?? identifier.aggregateId ?? nanoid()
|
|
26
|
+
const event = {
|
|
27
|
+
...identifier,
|
|
28
|
+
resourceId,
|
|
29
|
+
type: identifier.type ?? AggregateRoot.SchemaId,
|
|
30
|
+
}
|
|
31
|
+
return Promise.resolve(new SchemaStream(AggregateRoot, resourceId, [], null))
|
|
32
|
+
.then(SchemaStream.Add(event))
|
|
46
33
|
}
|
|
47
34
|
|
|
48
|
-
log.
|
|
49
|
-
return
|
|
35
|
+
log.info(`[Aggregate][Of] ${AggregateRoot.SchemaId}`)
|
|
36
|
+
return SchemaStream.Of(AggregateRoot, identifier)
|
|
50
37
|
}
|
|
51
38
|
|
|
52
|
-
static Find(identifier) {
|
|
53
|
-
|
|
54
|
-
return withMongoReconnect(() => Aggregate.Collection.findOne({ id: identifier }))
|
|
55
|
-
.then(aggregate => {
|
|
56
|
-
if (!aggregate) {
|
|
57
|
-
log.debug(`[Aggregate][Find()] No aggregate found for ${identifier}`)
|
|
58
|
-
}
|
|
59
|
-
return aggregate
|
|
60
|
-
})
|
|
39
|
+
static Find (identifier) {
|
|
40
|
+
return SchemaStream.Find(identifier)
|
|
61
41
|
}
|
|
62
42
|
|
|
63
|
-
static Add(event) {
|
|
64
|
-
return
|
|
65
|
-
|
|
66
|
-
return EventStore.AppendEvent({
|
|
67
|
-
...event,
|
|
68
|
-
id: nanoid(),
|
|
69
|
-
created: Date.now(),
|
|
70
|
-
createdBy: event.createdBy,
|
|
71
|
-
aggregateType: aggregate.type,
|
|
72
|
-
aggregateId: aggregate.id,
|
|
73
|
-
aggregateVersion: aggregate.version + 1,
|
|
74
|
-
})
|
|
75
|
-
.then(savedEvent => {
|
|
76
|
-
aggregate.version = savedEvent.aggregateVersion,
|
|
77
|
-
aggregate.events = [...aggregate.events, savedEvent]
|
|
78
|
-
return aggregate
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
|
|
43
|
+
static Add (event) {
|
|
44
|
+
return stream => SchemaStream.Add(event)(stream)
|
|
82
45
|
}
|
|
83
46
|
|
|
84
|
-
static Save() {
|
|
85
|
-
return
|
|
86
|
-
log.debug(`[Aggregate][Save] Saving state for ${aggregate.type} ${aggregate.id}`)
|
|
87
|
-
|
|
88
|
-
if (!aggregate.events || aggregate.events.length === 0) {
|
|
89
|
-
log.debug(`[Aggregate][Save] No events to save for ${aggregate.type} ${aggregate.id}`)
|
|
90
|
-
return Promise.resolve()
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const latestEventVersion = [...aggregate.events].sort((a, b) => a.aggregateVersion - b.aggregateVersion).pop()?.aggregateVersion
|
|
94
|
-
|
|
95
|
-
if (latestEventVersion < aggregate.version) {
|
|
96
|
-
log.debug(`[Aggregate][Save] No new events to save for ${aggregate.type} ${aggregate.id}`)
|
|
97
|
-
return Promise.resolve()
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const state = aggregate.View(aggregate.events, aggregate.state)
|
|
101
|
-
|
|
102
|
-
return withMongoReconnect(() =>
|
|
103
|
-
Aggregate.Collection.updateOne({ id: aggregate.id }, {
|
|
104
|
-
$set: {
|
|
105
|
-
id: aggregate.id,
|
|
106
|
-
version: latestEventVersion,
|
|
107
|
-
type: aggregate.type,
|
|
108
|
-
state: state
|
|
109
|
-
}
|
|
110
|
-
}, { upsert: true })
|
|
111
|
-
)
|
|
112
|
-
.catch(error => {
|
|
113
|
-
log.error(`[Aggregate][Save] Failed to save state for ${aggregate.type} ${aggregate.id}`, undefined, error)
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
static Validate(validator) {
|
|
119
|
-
return aggregate => Promise.resolve()
|
|
120
|
-
.then(() => validator(aggregate.events, aggregate.state))
|
|
121
|
-
.then(() => aggregate)
|
|
47
|
+
static Save () {
|
|
48
|
+
return stream => SchemaStream.Save()(stream)
|
|
122
49
|
}
|
|
123
50
|
|
|
124
|
-
static
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (typeof view === 'function') {
|
|
129
|
-
return view(aggregate.events, aggregate.state)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return aggregate.View(aggregate.events, aggregate.state)
|
|
133
|
-
}
|
|
134
|
-
|
|
51
|
+
static Validate (validator) {
|
|
52
|
+
return stream => Promise.resolve()
|
|
53
|
+
.then(() => validator(stream.events, stream.state))
|
|
54
|
+
.then(() => stream)
|
|
135
55
|
}
|
|
136
56
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
*
|
|
140
|
-
* @param {string} id - Id of aggregate - aggregateId
|
|
141
|
-
* @param {string} root - root aggregate (class that aggregates the events)
|
|
142
|
-
* @param {Event[]} events - The events that make up the stream
|
|
143
|
-
* @param {Object} aggregate - The object representing the aggregate of all the events
|
|
144
|
-
*/
|
|
145
|
-
constructor(AggregateRoot, id, events, aggregate) {
|
|
146
|
-
log.debug(`[Aggregate][Constructor] Assembling ${AggregateRoot.AggregateType} ${id}`)
|
|
147
|
-
|
|
148
|
-
this.id = id;
|
|
149
|
-
this.version = aggregate?.version || 0
|
|
150
|
-
this.type = AggregateRoot.AggregateType
|
|
151
|
-
this.events = events || []
|
|
152
|
-
this.state = aggregate?.state
|
|
153
|
-
|
|
154
|
-
this.View = AggregateRoot.View
|
|
57
|
+
static View (view) {
|
|
58
|
+
return stream => SchemaStream.View(view)(stream)
|
|
155
59
|
}
|
|
156
|
-
|
|
157
60
|
}
|
package/src/event-store.js
CHANGED
|
@@ -4,108 +4,101 @@ import { createLogger } from '@ossy/observability'
|
|
|
4
4
|
const log = createLogger('event-store')
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* ADR 0008 resource event append.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} event
|
|
10
|
+
* @returns {Promise<object>}
|
|
11
|
+
*/
|
|
12
|
+
export function appendResourceEvent (event) {
|
|
13
|
+
log.info('[EventStore] Appending resource event')
|
|
14
|
+
return withMongoReconnect(() => Mongo.db.collection('eventstore').insertOne(event))
|
|
15
|
+
.then(insertResult => (insertResult.acknowledged ? event : Promise.reject()))
|
|
16
|
+
.catch(error => {
|
|
17
|
+
log.error('[EventStore] Could not append resource event', undefined, error)
|
|
18
|
+
return Promise.reject(error)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ resourceId: string, fromVersion?: number }} opts
|
|
24
|
+
* @returns {Promise<object[]>}
|
|
25
|
+
*/
|
|
26
|
+
export function getResourceStream ({ resourceId, fromVersion = 0 }) {
|
|
27
|
+
const query = {
|
|
28
|
+
resourceId,
|
|
29
|
+
version: { $gt: fromVersion },
|
|
30
|
+
}
|
|
31
|
+
return withMongoReconnect(() =>
|
|
32
|
+
EventStore.Collection.find(query, { sort: { version: 1 } }).toArray(),
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @returns {Promise<{ resourceId: string, type: string }[]>}
|
|
38
|
+
*/
|
|
39
|
+
export function getResourceStreams () {
|
|
40
|
+
return EventStore.Aggregate([
|
|
41
|
+
{ $match: { resourceId: { $exists: true, $ne: null } } },
|
|
42
|
+
{
|
|
43
|
+
$group: {
|
|
44
|
+
_id: { resourceId: '$resourceId', type: '$type' },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
$project: {
|
|
49
|
+
_id: 0,
|
|
50
|
+
resourceId: '$_id.resourceId',
|
|
51
|
+
type: '$_id.type',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
8
58
|
* @class
|
|
9
59
|
*/
|
|
10
60
|
export class EventStore {
|
|
11
61
|
|
|
12
|
-
static get Collection() {
|
|
62
|
+
static get Collection () {
|
|
13
63
|
return Mongo.db.collection('eventstore')
|
|
14
64
|
}
|
|
15
65
|
|
|
16
|
-
static
|
|
17
|
-
|
|
18
|
-
|
|
66
|
+
static AppendResourceEvent (event) {
|
|
67
|
+
return appendResourceEvent(event)
|
|
68
|
+
}
|
|
19
69
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return insertResult.acknowledged
|
|
23
|
-
? Promise.resolve(event)
|
|
24
|
-
: Promise.reject()
|
|
25
|
-
})
|
|
26
|
-
.catch(error => {
|
|
27
|
-
log.error('[EventStore] Could not append event', undefined, error)
|
|
28
|
-
return Promise.reject()
|
|
29
|
-
})
|
|
70
|
+
static GetResourceStream (opts) {
|
|
71
|
+
return getResourceStream(opts)
|
|
30
72
|
}
|
|
31
73
|
|
|
32
|
-
static
|
|
33
|
-
|
|
34
|
-
|
|
74
|
+
static GetResourceStreams () {
|
|
75
|
+
return getResourceStreams()
|
|
76
|
+
}
|
|
35
77
|
|
|
78
|
+
static FindEvent (query) {
|
|
79
|
+
log.info('[EventStore] Searching for event')
|
|
36
80
|
return withMongoReconnect(() => EventStore.Collection.findOne(query))
|
|
37
|
-
.then(event =>
|
|
81
|
+
.then(event => (event ? event : Promise.reject()))
|
|
38
82
|
.catch(error => {
|
|
39
83
|
log.error('[EventStore] No event found', undefined, error)
|
|
40
|
-
return Promise.reject()
|
|
84
|
+
return Promise.reject(error)
|
|
41
85
|
})
|
|
42
86
|
}
|
|
43
87
|
|
|
44
|
-
static FindEvents(query) {
|
|
88
|
+
static FindEvents (query) {
|
|
45
89
|
log.info('[EventStore] Searching for events')
|
|
46
|
-
log.debug('[EventStore] Query', { query })
|
|
47
|
-
|
|
48
90
|
return withMongoReconnect(() =>
|
|
49
|
-
EventStore.Collection.find(query, { sort: {
|
|
91
|
+
EventStore.Collection.find(query, { sort: { version: 1, created: 1 } }).toArray(),
|
|
50
92
|
)
|
|
51
|
-
.then(events =>
|
|
93
|
+
.then(events => (events?.length ? events : Promise.reject()))
|
|
52
94
|
.catch(error => {
|
|
53
95
|
log.error('[EventStore] No events found', undefined, error)
|
|
54
|
-
return Promise.reject()
|
|
55
|
-
})
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
static GetEventStream({
|
|
59
|
-
aggregateId,
|
|
60
|
-
fromVersion = 0
|
|
61
|
-
}) {
|
|
62
|
-
log.debug(`[EventStore][GetEventStream()] Fetching stream ${aggregateId} from version ${fromVersion}`)
|
|
63
|
-
|
|
64
|
-
const query = {
|
|
65
|
-
aggregateId: aggregateId,
|
|
66
|
-
aggregateVersion: { '$gt': fromVersion }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const options = {
|
|
70
|
-
sort: { aggregateVersion: 1 }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return withMongoReconnect(() => EventStore.Collection.find(query, options).toArray())
|
|
74
|
-
.then(events => {
|
|
75
|
-
log.debug(`[EventStore][GetEventStream()] Found ${events.length} events for ${aggregateId}`)
|
|
76
|
-
return events
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
static GetEventStreams() {
|
|
81
|
-
log.info('[EventStore][GetEventStreams()] Fetching event streams')
|
|
82
|
-
|
|
83
|
-
return EventStore.Aggregate([
|
|
84
|
-
{
|
|
85
|
-
'$group': {
|
|
86
|
-
'_id': {
|
|
87
|
-
'aggregateId': '$aggregateId',
|
|
88
|
-
'aggregateType': '$aggregateType'
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
'$project': {
|
|
94
|
-
'_id': 0,
|
|
95
|
-
'aggregateId': '$_id.aggregateId',
|
|
96
|
-
'aggregateType': '$_id.aggregateType'
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
])
|
|
100
|
-
.then(result => {
|
|
101
|
-
log.debug(`[EventStore][GetEventStreams()] found ${result.length} streams`)
|
|
102
|
-
return result
|
|
96
|
+
return Promise.reject(error)
|
|
103
97
|
})
|
|
104
98
|
}
|
|
105
99
|
|
|
106
|
-
static Aggregate(pipeline) {
|
|
100
|
+
static Aggregate (pipeline) {
|
|
107
101
|
log.info('[EventStore] Running aggregation pipeline')
|
|
108
|
-
|
|
109
102
|
return withMongoReconnect(() => EventStore.Collection.aggregate(pipeline).toArray())
|
|
110
103
|
.then(events => (Array.isArray(events) ? events : []))
|
|
111
104
|
.catch(error => {
|
|
@@ -114,8 +107,7 @@ export class EventStore {
|
|
|
114
107
|
})
|
|
115
108
|
}
|
|
116
109
|
|
|
117
|
-
static CloseDbConnection() {
|
|
110
|
+
static CloseDbConnection () {
|
|
118
111
|
return Mongo.closeConnection()
|
|
119
112
|
}
|
|
120
|
-
|
|
121
113
|
}
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export * from './mongodb.js'
|
|
2
2
|
export * from './event-store.js'
|
|
3
3
|
export * from './aggregate.js'
|
|
4
|
+
export * from './schema-stream.js'
|
|
4
5
|
export * from './aggregate-rebuild.js'
|
|
6
|
+
export * from './projection-rebuild.js'
|
|
7
|
+
export { getProjection } from './projection-queries.js'
|
|
8
|
+
export { PushInvalidation, buildInvalidationKeys, buildPushMessage } from './push-invalidation.js'
|
package/src/mongodb.js
CHANGED
|
@@ -12,13 +12,27 @@ if (!globalThis.require) {
|
|
|
12
12
|
|
|
13
13
|
const log = createLogger('event-store')
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
const DEFAULT_MONGO_URL = 'mongodb://mongodb:27017/'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve MongoDB URL at call time so dotenv can load before the first connection.
|
|
19
|
+
* Adds directConnection for localhost access to Docker replica sets (member host `mongodb`
|
|
20
|
+
* does not resolve on the host — see packages/platform/src/test/jest.setup.js).
|
|
21
|
+
*/
|
|
22
|
+
export function resolveMongoUrl(url = process.env.DB_URL) {
|
|
23
|
+
const resolved = url || DEFAULT_MONGO_URL
|
|
24
|
+
if (/^mongodb\+srv:/i.test(resolved)) return resolved
|
|
25
|
+
if (/[?&]directConnection=/i.test(resolved)) return resolved
|
|
26
|
+
if (/127\.0\.0\.1|localhost(?=[:/]|$)/i.test(resolved)) {
|
|
27
|
+
return resolved.includes('?') ? `${resolved}&directConnection=true` : `${resolved}?directConnection=true`
|
|
28
|
+
}
|
|
29
|
+
return resolved
|
|
30
|
+
}
|
|
17
31
|
|
|
18
32
|
let mongoClient = null
|
|
19
33
|
|
|
20
34
|
function createClient() {
|
|
21
|
-
return new MongoClient(
|
|
35
|
+
return new MongoClient(resolveMongoUrl(), {
|
|
22
36
|
serverSelectionTimeoutMS: 30_000,
|
|
23
37
|
})
|
|
24
38
|
}
|
|
@@ -53,7 +67,8 @@ export class Mongo {
|
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
static get db() {
|
|
56
|
-
|
|
70
|
+
const dbName = process.env.DB_NAME || 'test'
|
|
71
|
+
return Mongo.Client.db(dbName)
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
static resetClient() {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { withMongoReconnect } from './mongodb.js'
|
|
2
|
+
import { ProjectionRebuild } from './projection-rebuild.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load a projection snapshot by scope id and projection type id.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} scopeId e.g. workspaceId
|
|
8
|
+
* @param {string} projectionId e.g. `@ossy/booking/data/booking-list`
|
|
9
|
+
* @returns {Promise<object | null>}
|
|
10
|
+
*/
|
|
11
|
+
export async function getProjection (scopeId, projectionId) {
|
|
12
|
+
if (!scopeId || !projectionId) return null
|
|
13
|
+
const doc = await withMongoReconnect(() =>
|
|
14
|
+
ProjectionRebuild.Collection.findOne({ id: scopeId, type: projectionId }),
|
|
15
|
+
)
|
|
16
|
+
return doc?.state ?? null
|
|
17
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { matchesSource } from '@ossy/fold'
|
|
2
|
+
import { EventStore } from './event-store.js'
|
|
3
|
+
import { Mongo, withMongoReconnect } from './mongodb.js'
|
|
4
|
+
import { createLogger } from '@ossy/observability'
|
|
5
|
+
|
|
6
|
+
const log = createLogger('event-store')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ADR 0008 projection aggregate rebuild — derived read models keyed by `scopeId`.
|
|
10
|
+
*/
|
|
11
|
+
export class ProjectionRebuild {
|
|
12
|
+
|
|
13
|
+
/** @type {Function[]} */
|
|
14
|
+
static _projections = []
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ id: string, Aggregate: Function }} mod
|
|
18
|
+
*/
|
|
19
|
+
static registerProjection (mod) {
|
|
20
|
+
const { Aggregate: ProjectionClass } = mod
|
|
21
|
+
if (ProjectionClass?.kind !== 'projection' || typeof ProjectionClass.Apply !== 'function') {
|
|
22
|
+
log.warn('[ProjectionRebuild] registerProjection: expected kind=projection with Apply()')
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
ProjectionRebuild._projections.push(ProjectionClass)
|
|
26
|
+
log.debug(`[ProjectionRebuild] Registered projection: ${mod.id ?? ProjectionClass.ProjectionId}`)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static get Collection () {
|
|
30
|
+
return Mongo.db.collection('aggregates')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async dispatch (event) {
|
|
34
|
+
if (!event?.resourceId) return
|
|
35
|
+
|
|
36
|
+
for (const ProjectionClass of ProjectionRebuild._projections) {
|
|
37
|
+
const sources = ProjectionClass.sources ?? []
|
|
38
|
+
if (!sources.some(source => matchesSource(source, event))) continue
|
|
39
|
+
|
|
40
|
+
const scopeId = typeof ProjectionClass.scopeFromEvent === 'function'
|
|
41
|
+
? ProjectionClass.scopeFromEvent(event)
|
|
42
|
+
: null
|
|
43
|
+
if (!scopeId) continue
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await ProjectionRebuild._applyOne(ProjectionClass, scopeId, event)
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.error(`[ProjectionRebuild] failed for ${ProjectionClass.ProjectionId}`, undefined, err)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Replay all matching events and rebuild projection snapshots (startup). */
|
|
54
|
+
static async rebuildAll () {
|
|
55
|
+
for (const ProjectionClass of ProjectionRebuild._projections) {
|
|
56
|
+
const sources = ProjectionClass.sources ?? []
|
|
57
|
+
if (!sources.length) continue
|
|
58
|
+
|
|
59
|
+
const orClauses = sources.map(s => {
|
|
60
|
+
const clause = { type: s.type, event: s.event }
|
|
61
|
+
return clause
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const events = await EventStore.Collection.find({ $or: orClauses })
|
|
65
|
+
.sort({ created: 1, version: 1 })
|
|
66
|
+
.toArray()
|
|
67
|
+
|
|
68
|
+
const byScope = new Map()
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
if (!sources.some(source => matchesSource(source, event))) continue
|
|
71
|
+
const scopeId = ProjectionClass.scopeFromEvent?.(event)
|
|
72
|
+
if (!scopeId) continue
|
|
73
|
+
if (!byScope.has(scopeId)) byScope.set(scopeId, [])
|
|
74
|
+
byScope.get(scopeId).push(event)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const [scopeId, scopeEvents] of byScope) {
|
|
78
|
+
let state = ProjectionClass.initialState?.(scopeId) ?? {}
|
|
79
|
+
for (const event of scopeEvents) {
|
|
80
|
+
state = ProjectionClass.Apply(event, state) ?? state
|
|
81
|
+
}
|
|
82
|
+
const version = scopeEvents.length
|
|
83
|
+
const last = scopeEvents[scopeEvents.length - 1]
|
|
84
|
+
const cursor = last ? { eventId: last.id, eventVersion: last.version } : null
|
|
85
|
+
|
|
86
|
+
await withMongoReconnect(() =>
|
|
87
|
+
ProjectionRebuild.Collection.updateOne(
|
|
88
|
+
{ id: scopeId, type: ProjectionClass.ProjectionId },
|
|
89
|
+
{
|
|
90
|
+
$set: {
|
|
91
|
+
id: scopeId,
|
|
92
|
+
type: ProjectionClass.ProjectionId,
|
|
93
|
+
kind: 'projection',
|
|
94
|
+
version,
|
|
95
|
+
state: { ...state, version, cursor },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{ upsert: true },
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
log.info(`[ProjectionRebuild] Rebuilt ${ProjectionClass.ProjectionId} (${byScope.size} scopes)`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static async _applyOne (ProjectionClass, scopeId, event) {
|
|
108
|
+
const projectionId = ProjectionClass.ProjectionId
|
|
109
|
+
const snapshot = await withMongoReconnect(() =>
|
|
110
|
+
ProjectionRebuild.Collection.findOne({ id: scopeId, type: projectionId }),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const state = snapshot?.state ?? ProjectionClass.initialState?.(scopeId) ?? {}
|
|
114
|
+
const next = ProjectionClass.Apply(event, state)
|
|
115
|
+
if (!next) return
|
|
116
|
+
|
|
117
|
+
const version = (snapshot?.version ?? 0) + 1
|
|
118
|
+
const cursor = { eventId: event.id, eventVersion: event.version }
|
|
119
|
+
|
|
120
|
+
await withMongoReconnect(() =>
|
|
121
|
+
ProjectionRebuild.Collection.updateOne(
|
|
122
|
+
{ id: scopeId, type: projectionId },
|
|
123
|
+
{
|
|
124
|
+
$set: {
|
|
125
|
+
id: scopeId,
|
|
126
|
+
type: projectionId,
|
|
127
|
+
kind: 'projection',
|
|
128
|
+
version,
|
|
129
|
+
state: { ...next, version, cursor },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{ upsert: true },
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { matchesSource } from '@ossy/fold'
|
|
2
|
+
import { ProjectionRebuild } from './projection-rebuild.js'
|
|
3
|
+
import { createLogger } from '@ossy/observability'
|
|
4
|
+
|
|
5
|
+
const log = createLogger('event-store')
|
|
6
|
+
|
|
7
|
+
function normalizeLocation (loc) {
|
|
8
|
+
if (loc == null || loc === '') return '/'
|
|
9
|
+
const trimmed = String(loc).replace(/^\/+|\/+$/g, '')
|
|
10
|
+
return trimmed ? `/${trimmed}/` : '/'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* ADR 0008 §9 — derive client cache keys from a resource event.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} event eventstore document
|
|
17
|
+
* @returns {string[]}
|
|
18
|
+
*/
|
|
19
|
+
export function buildInvalidationKeys (event) {
|
|
20
|
+
if (!event?.resourceId) return []
|
|
21
|
+
|
|
22
|
+
const keys = new Set()
|
|
23
|
+
keys.add(`resource:${event.resourceId}`)
|
|
24
|
+
|
|
25
|
+
const location = event.payload?.location
|
|
26
|
+
if (typeof location === 'string' && location.length > 0) {
|
|
27
|
+
keys.add(`location:${normalizeLocation(location)}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parentLocation = event.payload?.parentLocation
|
|
31
|
+
if (typeof parentLocation === 'string' && parentLocation.length > 0) {
|
|
32
|
+
keys.add(`location:${normalizeLocation(parentLocation)}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const ProjectionClass of ProjectionRebuild._projections) {
|
|
36
|
+
const sources = ProjectionClass.sources ?? []
|
|
37
|
+
if (!sources.some(source => matchesSource(source, event))) continue
|
|
38
|
+
|
|
39
|
+
const scopeId = ProjectionClass.scopeFromEvent?.(event)
|
|
40
|
+
if (!scopeId) continue
|
|
41
|
+
|
|
42
|
+
const projectionKeys = typeof ProjectionClass.cacheKeys === 'function'
|
|
43
|
+
? ProjectionClass.cacheKeys(scopeId, event)
|
|
44
|
+
: [`projection:${ProjectionClass.ProjectionId}:${scopeId}`]
|
|
45
|
+
|
|
46
|
+
for (const key of projectionKeys) {
|
|
47
|
+
if (key) keys.add(key)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [...keys]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} event eventstore document
|
|
56
|
+
* @returns {object | null}
|
|
57
|
+
*/
|
|
58
|
+
export function buildPushMessage (event) {
|
|
59
|
+
if (!event?.resourceId) return null
|
|
60
|
+
|
|
61
|
+
const workspaceId = event.payload?.belongsTo ?? null
|
|
62
|
+
const invalidate = buildInvalidationKeys(event)
|
|
63
|
+
if (!invalidate.length) return null
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
kind: 'resource.updated',
|
|
67
|
+
type: event.type,
|
|
68
|
+
resourceId: event.resourceId,
|
|
69
|
+
event: event.event,
|
|
70
|
+
version: event.version,
|
|
71
|
+
eventId: event.id,
|
|
72
|
+
scope: workspaceId ? { workspaceId } : {},
|
|
73
|
+
invalidate,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Workspace-scoped SSE fan-out for cache invalidation (ADR 0008 §9).
|
|
79
|
+
*/
|
|
80
|
+
export class PushInvalidation {
|
|
81
|
+
|
|
82
|
+
/** @type {Map<string, Set<Function>>} */
|
|
83
|
+
static _subscribers = new Map()
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} workspaceId
|
|
87
|
+
* @param {(message: object) => void} send
|
|
88
|
+
* @returns {() => void}
|
|
89
|
+
*/
|
|
90
|
+
static subscribe (workspaceId, send) {
|
|
91
|
+
if (!workspaceId) return () => {}
|
|
92
|
+
|
|
93
|
+
if (!PushInvalidation._subscribers.has(workspaceId)) {
|
|
94
|
+
PushInvalidation._subscribers.set(workspaceId, new Set())
|
|
95
|
+
}
|
|
96
|
+
const listeners = PushInvalidation._subscribers.get(workspaceId)
|
|
97
|
+
listeners.add(send)
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
listeners.delete(send)
|
|
101
|
+
if (listeners.size === 0) {
|
|
102
|
+
PushInvalidation._subscribers.delete(workspaceId)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Publish invalidation after changestream rebuild.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} event eventstore document
|
|
111
|
+
*/
|
|
112
|
+
static publish (event) {
|
|
113
|
+
const message = buildPushMessage(event)
|
|
114
|
+
if (!message) return
|
|
115
|
+
|
|
116
|
+
const workspaceId = message.scope?.workspaceId
|
|
117
|
+
if (!workspaceId) return
|
|
118
|
+
|
|
119
|
+
const listeners = PushInvalidation._subscribers.get(workspaceId)
|
|
120
|
+
if (!listeners?.size) return
|
|
121
|
+
|
|
122
|
+
for (const send of listeners) {
|
|
123
|
+
try {
|
|
124
|
+
send(message)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log.warn('[PushInvalidation] listener failed', undefined, err)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { buildInvalidationKeys, buildPushMessage } from './push-invalidation.js'
|
|
3
|
+
import { ProjectionRebuild } from './projection-rebuild.js'
|
|
4
|
+
|
|
5
|
+
describe('push-invalidation', () => {
|
|
6
|
+
it('builds resource and location keys', () => {
|
|
7
|
+
const keys = buildInvalidationKeys({
|
|
8
|
+
resourceId: 'res1',
|
|
9
|
+
type: '@ossy/platform/schema/file',
|
|
10
|
+
event: 'Created',
|
|
11
|
+
payload: { location: '/docs/', belongsTo: 'ws1' },
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
expect(keys).toContain('resource:res1')
|
|
15
|
+
expect(keys).toContain('location:/docs/')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('includes projection cache keys when projection matches', () => {
|
|
19
|
+
class TestProjection {
|
|
20
|
+
static kind = 'projection'
|
|
21
|
+
static ProjectionId = '@ossy/test/data/list'
|
|
22
|
+
static sources = [{ type: '@ossy/test/schema/item', event: 'Created' }]
|
|
23
|
+
static scopeFromEvent (event) { return event.payload?.belongsTo }
|
|
24
|
+
static cacheKeys (scopeId) {
|
|
25
|
+
return [`projection:@ossy/test/data/list:${scopeId}`, 'action:@ossy/test/actions/list']
|
|
26
|
+
}
|
|
27
|
+
static Apply () { return {} }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ProjectionRebuild._projections.push(TestProjection)
|
|
31
|
+
|
|
32
|
+
const keys = buildInvalidationKeys({
|
|
33
|
+
resourceId: 'item1',
|
|
34
|
+
type: '@ossy/test/schema/item',
|
|
35
|
+
event: 'Created',
|
|
36
|
+
payload: { belongsTo: 'ws9' },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(keys).toContain('projection:@ossy/test/data/list:ws9')
|
|
40
|
+
expect(keys).toContain('action:@ossy/test/actions/list')
|
|
41
|
+
|
|
42
|
+
ProjectionRebuild._projections.pop()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('builds push message scoped to workspace', () => {
|
|
46
|
+
const message = buildPushMessage({
|
|
47
|
+
id: 'evt1',
|
|
48
|
+
resourceId: 'res1',
|
|
49
|
+
type: '@ossy/booking/schema/booking',
|
|
50
|
+
event: 'Patched',
|
|
51
|
+
version: 3,
|
|
52
|
+
payload: { belongsTo: 'ws1', location: '/@ossy/booking/' },
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(message).toEqual(expect.objectContaining({
|
|
56
|
+
kind: 'resource.updated',
|
|
57
|
+
resourceId: 'res1',
|
|
58
|
+
version: 3,
|
|
59
|
+
eventId: 'evt1',
|
|
60
|
+
scope: { workspaceId: 'ws1' },
|
|
61
|
+
}))
|
|
62
|
+
expect(message.invalidate).toContain('resource:res1')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { EventStore, getResourceStream } from './event-store.js'
|
|
3
|
+
import { Mongo, withMongoReconnect } from './mongodb.js'
|
|
4
|
+
import { createLogger } from '@ossy/observability'
|
|
5
|
+
|
|
6
|
+
const log = createLogger('event-store')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ADR 0008 schema-backed entity stream — keyed by `resourceId`.
|
|
10
|
+
*/
|
|
11
|
+
export class SchemaStream {
|
|
12
|
+
|
|
13
|
+
static get Collection () {
|
|
14
|
+
return Mongo.db.collection('aggregates')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Function} AggregateRoot aggregate class with `SchemaId` and `View`
|
|
19
|
+
* @param {string | object} identifier resourceId or Created event partial
|
|
20
|
+
*/
|
|
21
|
+
static Of (AggregateRoot, identifier) {
|
|
22
|
+
if (typeof identifier === 'object' && identifier !== null) {
|
|
23
|
+
const resourceId = identifier.resourceId ?? identifier.aggregateId ?? nanoid()
|
|
24
|
+
return Promise.resolve(new SchemaStream(AggregateRoot, resourceId, [], null))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return SchemaStream.Find(identifier)
|
|
28
|
+
.then(snapshot =>
|
|
29
|
+
getResourceStream({ resourceId: identifier, fromVersion: snapshot?.version ?? 0 })
|
|
30
|
+
.then(events => new SchemaStream(AggregateRoot, identifier, events, snapshot)),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static Find (resourceId) {
|
|
35
|
+
return withMongoReconnect(() => SchemaStream.Collection.findOne({ id: resourceId }))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static Add (event) {
|
|
39
|
+
return stream => {
|
|
40
|
+
const version = stream.version + 1
|
|
41
|
+
const schemaId = event.type ?? stream.events[0]?.type ?? stream.AggregateRoot?.SchemaId
|
|
42
|
+
const saved = {
|
|
43
|
+
...event,
|
|
44
|
+
type: schemaId,
|
|
45
|
+
id: nanoid(),
|
|
46
|
+
created: Date.now(),
|
|
47
|
+
resourceId: stream.id,
|
|
48
|
+
version,
|
|
49
|
+
}
|
|
50
|
+
return EventStore.AppendResourceEvent(saved)
|
|
51
|
+
.then(() => {
|
|
52
|
+
stream.version = version
|
|
53
|
+
stream.events = [...stream.events, saved]
|
|
54
|
+
return stream
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static Save () {
|
|
60
|
+
return stream => {
|
|
61
|
+
if (!stream.events?.length) return Promise.resolve()
|
|
62
|
+
|
|
63
|
+
const latestVersion = stream.events[stream.events.length - 1]?.version ?? stream.version
|
|
64
|
+
const state = stream.AggregateRoot.View(stream.events, stream.state)
|
|
65
|
+
const schemaId = state.type ?? stream.events[0]?.type ?? stream.AggregateRoot.SchemaId
|
|
66
|
+
|
|
67
|
+
return withMongoReconnect(() =>
|
|
68
|
+
SchemaStream.Collection.updateOne(
|
|
69
|
+
{ id: stream.id },
|
|
70
|
+
{
|
|
71
|
+
$set: {
|
|
72
|
+
id: stream.id,
|
|
73
|
+
version: latestVersion,
|
|
74
|
+
type: schemaId,
|
|
75
|
+
state,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{ upsert: true },
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
.then(() => stream)
|
|
82
|
+
.catch(err => {
|
|
83
|
+
log.error(`[SchemaStream] save failed for ${stream.id}`, undefined, err)
|
|
84
|
+
return Promise.reject(err)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
static View (view) {
|
|
90
|
+
return stream => {
|
|
91
|
+
if (typeof view === 'function') return view(stream.events, stream.state)
|
|
92
|
+
return stream.AggregateRoot.View(stream.events, stream.state)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
constructor (AggregateRoot, id, events, snapshot) {
|
|
97
|
+
this.AggregateRoot = AggregateRoot
|
|
98
|
+
this.id = id
|
|
99
|
+
this.version = snapshot?.version ?? 0
|
|
100
|
+
this.state = snapshot?.state
|
|
101
|
+
this.events = events ?? []
|
|
102
|
+
this.View = AggregateRoot.View
|
|
103
|
+
}
|
|
104
|
+
}
|