@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 CHANGED
@@ -1,46 +1,59 @@
1
1
  # @ossy/event-store
2
2
 
3
- Event sourcing primitives for the Ossy platform. Provides `Aggregate` (stream interaction), `EventStore` (raw event I/O), and `AggregateRebuild` (startup snapshot rebuilding) on top of MongoDB.
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. Each document is one domain event with `aggregateId`, `aggregateType`, `aggregateVersion`, `type`, `payload`, `created`, and `createdBy`.
10
- - **`aggregates`** — denormalized state snapshots, rebuilt from the event stream. Used for fast queries. Rebuilt automatically at startup and kept up-to-date as new events arrive.
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
- The main entry point for reading and writing event streams. All methods return Promises and are chainable with `.then()`.
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 { Order } from '@acme/orders'
36
+ import { User } from '@ossy/users/server'
23
37
 
24
- // Fold all events for `orderId` through Order.View() and return the result
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 aggregate
41
+ ### Creating a new entity
29
42
 
30
- Pass an event object as the identifier. The event is appended and the new aggregate is returned.
43
+ Pass a creation event as the identifier. The event is appended and the stream is returned.
31
44
 
32
45
  ```js
33
- const newOrderEvent = { type: 'OrderPlaced', createdBy: userId, payload: { ... } }
34
- const orderView = await Aggregate.Of(Order, newOrderEvent).then(Aggregate.View())
46
+ const createdEvent = UsersEvents.Created({ email, firstName, lastName })
47
+ const userView = await Aggregate.Of(User, createdEvent).then(Aggregate.View())
35
48
  ```
36
49
 
37
- An `aggregateId` inside the event is used as the aggregate's id; if absent, a `nanoid()` is generated.
50
+ `resourceId` in the event is the entity id; if absent, the event factory generates one.
38
51
 
39
- ### Appending an event to an existing aggregate
52
+ ### Appending to an existing entity
40
53
 
41
54
  ```js
42
- await Aggregate.Of(Order, orderId)
43
- .then(Aggregate.Add({ type: 'OrderShipped', createdBy: userId, payload: { shippedAt: Date.now() } }))
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<Aggregate>` | Load an existing aggregate from the event store. |
52
- | `Aggregate.Of(Root, event)` | `(class, object) => Promise<Aggregate>` | Create a new aggregate by appending the first event. |
53
- | `Aggregate.Add(event)` | `(event) => (aggregate) => Promise<Aggregate>` | Append an event to the aggregate. Chainable. |
54
- | `Aggregate.Save()` | `() => (aggregate) => Promise<void>` | Persist the current state snapshot to the `aggregates` collection. |
55
- | `Aggregate.View(fn?)` | `(fn?) => (aggregate) => state` | Call the aggregate's `View(events, savedState)` and return the result. Pass a custom view function to override. |
56
- | `Aggregate.Validate(fn)` | `(fn) => (aggregate) => Promise<Aggregate>` | Run a validation function against `(events, savedState)`. Reject if the function throws. |
57
- | `Aggregate.Find(id)` | `(string) => Promise<object \| null>` | Low-level: find the snapshot document in `aggregates` by id. |
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
- ## Writing an aggregate class
77
+ ## `EventStore`
63
78
 
64
- An aggregate class is a pure reducer — no side effects, no I/O. It declares a `static AggregateType` string and a `static View(events, savedState)` function.
79
+ Low-level access to the `eventstore` collection.
65
80
 
66
81
  ```js
67
- // src/order.aggregate.js (inside an installable package)
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
- > Aggregate files must be in an **installable package** (a `node_modules/` package with `"ossy": { "src": "./src" }` in `package.json`). The platform never loads aggregates from the local app's `src/` to avoid duplicates.
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
- ## `EventStore`
97
+ ## Projections
103
98
 
104
- Low-level access to the `eventstore` collection. Prefer using `Aggregate` for most work.
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 { EventStore } from '@ossy/event-store'
102
+ import { getProjection } from '@ossy/event-store'
103
+
104
+ const list = await getProjection(workspaceId, '@ossy/booking/data/booking-list')
108
105
  ```
109
106
 
110
- | Method | Signature | Description |
111
- |---|---|---|
112
- | `EventStore.AppendEvent(event)` | `(object) => Promise<object>` | Insert an event document. Rejects on failure. |
113
- | `EventStore.GetEventStream({ aggregateId, fromVersion? })` | `=> Promise<object[]>` | Return all events for an aggregate id, optionally after a version. |
114
- | `EventStore.GetEventStreams()` | `=> Promise<{ aggregateId, aggregateType }[]>` | Return all known `(aggregateId, aggregateType)` pairs. |
115
- | `EventStore.FindEvent(query)` | `(MongoQuery) => Promise<object>` | Find a single event. Rejects when not found. |
116
- | `EventStore.FindEvents(query)` | `(MongoQuery) => Promise<object[]>` | Find multiple events. Rejects when none found. |
117
- | `EventStore.Aggregate(pipeline)` | `(Pipeline) => Promise<object[]>` | Run a MongoDB aggregation pipeline against the event store. |
118
- | `EventStore.Collection` | `Collection` | Direct access to the MongoDB `eventstore` collection. |
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 state snapshots from scratch at startup. Called automatically by `@ossy/platform` — you rarely need to use this directly.
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
- // Register an aggregate class (done automatically by the platform)
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) for the full specification.
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
- The platform server calls `Mongo.connect` automatically when `DB_URL` is present in the environment.
158
-
159
- Indexes recommended for the `eventstore` collection:
158
+ Recommended indexes for `eventstore`:
160
159
 
161
160
  ```js
162
- { aggregateId: 1, aggregateVersion: 1 } // stream queries
163
- { aggregateType: 1 } // type-level queries
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.2",
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
- "keywords": [],
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/observability": "^1.8.2",
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": "2b8745d57fee8b6c08787e2755b4df489291a5cd"
40
+ "gitHead": "4a70ec216448680d2fddc57b2a8a0077489720ae"
34
41
  }
@@ -1,65 +1,127 @@
1
- import { Aggregate } from './aggregate.js'
2
- import { EventStore } from './event-store.js'
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
- /** @type {Record<string, Function>} */
10
- static _aggregateMap = {}
11
-
12
- /**
13
- * Registers a pre-loaded aggregate module. Called by the platform server at
14
- * startup for each aggregate entry in the manifest, following the same pattern
15
- * as task and resource-template registration.
16
- *
17
- * @param {{ id: string, Aggregate: Function }} mod - The aggregate bundle module.
18
- */
19
- static registerAggregate(mod) {
20
- const { id, Aggregate: AggregateClass } = mod
21
- if (typeof AggregateClass !== 'function' || !id) {
22
- log.warn('[AggregateRebuild] registerAggregate: invalid module — expected { id, Aggregate }')
23
- return
24
- }
25
- AggregateRebuild._aggregateMap[id] = AggregateClass
26
- log.debug(`[AggregateRebuild] Registered aggregate: ${id}`)
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
- static async BuildAndSave(aggregateType, aggregateId) {
30
- const AggregateRoot = AggregateRebuild._aggregateMap[aggregateType]
59
+ const state = AggregateRoot
60
+ ? AggregateRoot.View(events, snapshot?.state)
61
+ : createDefaultReducer()(events, snapshot?.state)
31
62
 
32
- if (!AggregateRoot) {
33
- log.debug(`[AggregateRebuild][BuildAndSave] Skipping unregistered aggregate type: ${aggregateType}`)
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
- return Aggregate.Of(AggregateRoot, aggregateId)
38
- .then(Aggregate.Save())
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
- static async BuildAndSaveAll() {
42
- log.debug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
43
-
44
- const knownTypes = new Set(Object.keys(AggregateRebuild._aggregateMap))
45
-
46
- if (knownTypes.size === 0) {
47
- log.warn('[AggregateRebuild][BuildAndSaveAll] No aggregates registered — skipping rebuild')
48
- return
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 '@ossy/event-store'
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
- // Empty trigger matches all events. AggregateRebuild.BuildAndSave silently
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
- await AggregateRebuild.BuildAndSave(event.aggregateType, event.aggregateId)
16
+ if (!event?.resourceId) return
17
+
18
+ await AggregateRebuild.BuildAndSaveResource(event.resourceId, event.type, { event })
13
19
  }