@ossy/event-store 1.8.3 → 3.0.2

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