@ossy/event-store 1.8.2 → 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 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 })` — `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 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": "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
- "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": "^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": "2b8745d57fee8b6c08787e2755b4df489291a5cd"
40
+ "gitHead": "a0d89185a17f8de8ce328c3a648c108ff1d61d8f"
34
41
  }
@@ -1,65 +1,82 @@
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 = {}
12
+ /** @type {Record<string, Function>} */
13
+ static _aggregateMap = {}
11
14
 
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}`)
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
- static async BuildAndSave(aggregateType, aggregateId) {
30
- const AggregateRoot = AggregateRebuild._aggregateMap[aggregateType]
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
- if (!AggregateRoot) {
33
- log.debug(`[AggregateRebuild][BuildAndSave] Skipping unregistered aggregate type: ${aggregateType}`)
34
- return
35
- }
43
+ if (!events.length && !snapshot) return
36
44
 
37
- return Aggregate.Of(AggregateRoot, aggregateId)
38
- .then(Aggregate.Save())
39
- }
45
+ const state = AggregateRoot
46
+ ? AggregateRoot.View(events, snapshot?.state)
47
+ : createDefaultReducer()(events, snapshot?.state)
40
48
 
41
- static async BuildAndSaveAll() {
42
- log.debug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
49
+ const type = schemaId ?? state.type ?? events[0]?.type
50
+ const version = events[events.length - 1]?.version ?? snapshot?.version ?? 0
43
51
 
44
- const knownTypes = new Set(Object.keys(AggregateRebuild._aggregateMap))
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
- if (knownTypes.size === 0) {
47
- log.warn('[AggregateRebuild][BuildAndSaveAll] No aggregates registered — skipping rebuild')
48
- return
49
- }
61
+ static async BuildAndSaveAll () {
62
+ log.debug('[AggregateRebuild][BuildAndSaveAll] Starting building aggregates')
50
63
 
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
- })
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 '@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)
13
19
  }
package/src/aggregate.js CHANGED
@@ -1,157 +1,60 @@
1
1
  import { nanoid } from 'nanoid'
2
- import { Mongo, withMongoReconnect } from './mongodb.js'
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
- * Utility class that helps interact with event streams.
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 Mongo.db.collection('aggregates')
12
+ static get Collection () {
13
+ return SchemaStream.Collection
17
14
  }
18
15
 
19
- /**
20
- * Start interacting with an event stream
21
- *
22
- * @param {Class} AggregateRoot - Root aggregate (class that aggregates the events)
23
- * @param {string} id - Id of aggregate - aggregateId
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('[Aggregate] Creating new aggregate event stream')
45
- return Aggregate.Add(identifier)(new Aggregate(AggregateRoot, identifier.aggregateId || nanoid(), []))
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.error('[Aggregate] No recognizable identifier provided')
49
- return Promise.reject()
35
+ log.info(`[Aggregate][Of] ${AggregateRoot.SchemaId}`)
36
+ return SchemaStream.Of(AggregateRoot, identifier)
50
37
  }
51
38
 
52
- static Find(identifier) {
53
- log.info(`[Aggregate][Find()] Fetching aggregate for ${identifier}`)
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 aggregate => {
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 aggregate => {
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 View(view) {
125
- return aggregate => {
126
- log.debug(`[Aggregate][View] Building view for ${aggregate.type} ${aggregate.id}`)
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
- * Use the Aggregate.Of(type, id) static method instead
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
  }
@@ -4,108 +4,101 @@ import { createLogger } from '@ossy/observability'
4
4
  const log = createLogger('event-store')
5
5
 
6
6
  /**
7
- * Database queries related to workspace events
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 AppendEvent(event) {
17
- log.info('[EventStore] Appending event')
18
- log.debug('[EventStore] Event', { event })
66
+ static AppendResourceEvent (event) {
67
+ return appendResourceEvent(event)
68
+ }
19
69
 
20
- return withMongoReconnect(() => EventStore.Collection.insertOne(event))
21
- .then(insertResult => {
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 FindEvent(query) {
33
- log.info('[EventStore] Searching for event')
34
- log.debug('[EventStore] Query', { query })
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 => !!event ? event : Promise.reject())
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: { aggregateVersion: 1 } }).toArray()
91
+ EventStore.Collection.find(query, { sort: { version: 1, created: 1 } }).toArray(),
50
92
  )
51
- .then(events => !!events?.length ? events : Promise.reject())
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'
@@ -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
+ }