@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.
@@ -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
+ }