@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 +91 -82
- package/package.json +11 -4
- package/src/aggregate-rebuild.js +113 -51
- package/src/aggregate-rebuild.spec.js +54 -0
- package/src/aggregate-rebuild.task.js +13 -7
- package/src/aggregate.js +33 -130
- package/src/ensure-indexes.js +41 -0
- package/src/ensure-indexes.spec.js +9 -0
- package/src/event-store.js +70 -78
- package/src/index.js +5 -0
- package/src/projection-queries.js +17 -0
- package/src/projection-rebuild.js +136 -0
- package/src/push-invalidation.js +130 -0
- package/src/push-invalidation.spec.js +64 -0
- package/src/schema-stream.js +104 -0
|
@@ -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
|
+
}
|