@kronos-ts/eventsourcing 0.1.0
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/dist/append-condition.d.ts +20 -0
- package/dist/append-condition.d.ts.map +1 -0
- package/dist/append-condition.js +7 -0
- package/dist/append-condition.js.map +1 -0
- package/dist/append.d.ts +33 -0
- package/dist/append.d.ts.map +1 -0
- package/dist/append.js +65 -0
- package/dist/append.js.map +1 -0
- package/dist/consistency-marker.d.ts +28 -0
- package/dist/consistency-marker.d.ts.map +1 -0
- package/dist/consistency-marker.js +28 -0
- package/dist/consistency-marker.js.map +1 -0
- package/dist/event-sourced-repository.d.ts +23 -0
- package/dist/event-sourced-repository.d.ts.map +1 -0
- package/dist/event-sourced-repository.js +105 -0
- package/dist/event-sourced-repository.js.map +1 -0
- package/dist/event-storage-engine.d.ts +60 -0
- package/dist/event-storage-engine.d.ts.map +1 -0
- package/dist/event-storage-engine.js +2 -0
- package/dist/event-storage-engine.js.map +1 -0
- package/dist/event-store-transaction.d.ts +31 -0
- package/dist/event-store-transaction.d.ts.map +1 -0
- package/dist/event-store-transaction.js +28 -0
- package/dist/event-store-transaction.js.map +1 -0
- package/dist/event-store.d.ts +26 -0
- package/dist/event-store.d.ts.map +1 -0
- package/dist/event-store.js +2 -0
- package/dist/event-store.js.map +1 -0
- package/dist/in-memory-event-store.d.ts +14 -0
- package/dist/in-memory-event-store.d.ts.map +1 -0
- package/dist/in-memory-event-store.js +225 -0
- package/dist/in-memory-event-store.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/intercepting-event-store.d.ts +11 -0
- package/dist/intercepting-event-store.d.ts.map +1 -0
- package/dist/intercepting-event-store.js +47 -0
- package/dist/intercepting-event-store.js.map +1 -0
- package/dist/load.d.ts +43 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +36 -0
- package/dist/load.js.map +1 -0
- package/dist/snapshot-policy.d.ts +45 -0
- package/dist/snapshot-policy.d.ts.map +1 -0
- package/dist/snapshot-policy.js +34 -0
- package/dist/snapshot-policy.js.map +1 -0
- package/dist/snapshot-store.d.ts +42 -0
- package/dist/snapshot-store.d.ts.map +1 -0
- package/dist/snapshot-store.js +23 -0
- package/dist/snapshot-store.js.map +1 -0
- package/dist/sourcing-condition.d.ts +14 -0
- package/dist/sourcing-condition.d.ts.map +1 -0
- package/dist/sourcing-condition.js +7 -0
- package/dist/sourcing-condition.js.map +1 -0
- package/dist/tag-resolver.d.ts +30 -0
- package/dist/tag-resolver.d.ts.map +1 -0
- package/dist/tag-resolver.js +46 -0
- package/dist/tag-resolver.js.map +1 -0
- package/package.json +58 -0
- package/src/append-condition.ts +23 -0
- package/src/append.ts +99 -0
- package/src/consistency-marker.ts +43 -0
- package/src/event-sourced-repository.ts +141 -0
- package/src/event-storage-engine.ts +69 -0
- package/src/event-store-transaction.ts +58 -0
- package/src/event-store.ts +26 -0
- package/src/in-memory-event-store.ts +268 -0
- package/src/index.ts +73 -0
- package/src/intercepting-event-store.ts +70 -0
- package/src/load.ts +70 -0
- package/src/snapshot-policy.ts +73 -0
- package/src/snapshot-store.ts +67 -0
- package/src/sourcing-condition.ts +17 -0
- package/src/tag-resolver.ts +62 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type { EventMessage, EventCriteria, TagCriteria, TypeRestrictedCriteria, EitherCriteria, MessageStream, SequencedEvent, StreamingCondition, TrackingToken } from "@kronos-ts/messaging"
|
|
2
|
+
import { createMessageStream, globalSequenceToken, FIRST_TOKEN } from "@kronos-ts/messaging"
|
|
3
|
+
import { qualifiedNameToString } from "@kronos-ts/common"
|
|
4
|
+
import type { EventStore, SourcingResult } from "./event-store.js"
|
|
5
|
+
import type { AppendTransaction } from "./event-storage-engine.js"
|
|
6
|
+
import type { AppendCondition } from "./append-condition.js"
|
|
7
|
+
import type { SourcingCondition } from "./sourcing-condition.js"
|
|
8
|
+
import { markerAt } from "./consistency-marker.js"
|
|
9
|
+
import type { ConsistencyMarker } from "./consistency-marker.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* In-memory event store for testing and standalone usage.
|
|
13
|
+
* Events are stored in an ordered array with a global sequence position.
|
|
14
|
+
* Supports push-based streaming via open().
|
|
15
|
+
*/
|
|
16
|
+
export function createInMemoryEventStore(): EventStore {
|
|
17
|
+
const events: Array<{ position: bigint; event: EventMessage }> = []
|
|
18
|
+
let nextPosition = 0n
|
|
19
|
+
|
|
20
|
+
// Registered stream listeners — notified when events are appended
|
|
21
|
+
const streamListeners = new Set<() => void>()
|
|
22
|
+
|
|
23
|
+
// Push-based subscribers — notified with actual events on append
|
|
24
|
+
const eventSubscribers = new Set<(events: ReadonlyArray<EventMessage>) => Promise<void>>()
|
|
25
|
+
|
|
26
|
+
function matchesCriteria(event: EventMessage, criteria: EventCriteria): boolean {
|
|
27
|
+
switch (criteria.kind) {
|
|
28
|
+
case "tags":
|
|
29
|
+
return matchesTags(event, criteria)
|
|
30
|
+
case "any-tag":
|
|
31
|
+
return event.tags.length > 0
|
|
32
|
+
case "type-restricted":
|
|
33
|
+
return matchesTypeRestricted(event, criteria)
|
|
34
|
+
case "either":
|
|
35
|
+
return matchesEither(event, criteria)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function matchesTags(event: EventMessage, criteria: TagCriteria): boolean {
|
|
40
|
+
return criteria.tags.every((requiredTag) =>
|
|
41
|
+
event.tags.some(
|
|
42
|
+
(eventTag) =>
|
|
43
|
+
eventTag.key === requiredTag.key && eventTag.value === requiredTag.value,
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function matchesTypeRestricted(event: EventMessage, criteria: TypeRestrictedCriteria): boolean {
|
|
49
|
+
if (!matchesCriteria(event, criteria.inner)) return false
|
|
50
|
+
const eventType = qualifiedNameToString(event.name)
|
|
51
|
+
return criteria.types.includes(eventType)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function matchesEither(event: EventMessage, criteria: EitherCriteria): boolean {
|
|
55
|
+
return criteria.criteria.some((c) => matchesCriteria(event, c))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function notifyListeners() {
|
|
59
|
+
for (const listener of streamListeners) {
|
|
60
|
+
try { listener() } catch { /* ignore */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
async source(condition: SourcingCondition): Promise<SourcingResult> {
|
|
66
|
+
const start = condition.start ?? 0n
|
|
67
|
+
const matching = events
|
|
68
|
+
.filter((entry) => entry.position >= start)
|
|
69
|
+
.filter((entry) => matchesCriteria(entry.event, condition.criteria))
|
|
70
|
+
|
|
71
|
+
const lastPosition = matching.length > 0
|
|
72
|
+
? matching[matching.length - 1]!.position
|
|
73
|
+
: start > 0n ? start - 1n : -1n
|
|
74
|
+
|
|
75
|
+
const globalMarker = events.length > 0
|
|
76
|
+
? events[events.length - 1]!.position
|
|
77
|
+
: -1n
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
events: matching.map((e) => e.event),
|
|
81
|
+
marker: markerAt(globalMarker),
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async appendEvents(
|
|
86
|
+
newEvents: ReadonlyArray<EventMessage>,
|
|
87
|
+
condition?: AppendCondition,
|
|
88
|
+
): Promise<AppendTransaction> {
|
|
89
|
+
if (condition) {
|
|
90
|
+
const conflicting = events
|
|
91
|
+
.filter((entry) => entry.position > condition.marker.position)
|
|
92
|
+
.filter((entry) => matchesCriteria(entry.event, condition.criteria))
|
|
93
|
+
|
|
94
|
+
if (conflicting.length > 0) {
|
|
95
|
+
throw new AppendConditionError(
|
|
96
|
+
`Append condition violated: ${conflicting.length} conflicting event(s) ` +
|
|
97
|
+
`found after position ${condition.marker.position}`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Stage events — they're added to the store but we track the range
|
|
103
|
+
const startPosition = nextPosition
|
|
104
|
+
for (const event of newEvents) {
|
|
105
|
+
events.push({ position: nextPosition, event })
|
|
106
|
+
nextPosition++
|
|
107
|
+
}
|
|
108
|
+
const endPosition = nextPosition - 1n
|
|
109
|
+
let committed = false
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
async commit() {
|
|
113
|
+
committed = true
|
|
114
|
+
// Events are already in the array — notify listeners
|
|
115
|
+
notifyListeners()
|
|
116
|
+
for (const subscriber of eventSubscribers) {
|
|
117
|
+
try { await subscriber(newEvents) } catch { /* ignore */ }
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
async afterCommit() {
|
|
121
|
+
return markerAt(endPosition)
|
|
122
|
+
},
|
|
123
|
+
rollback() {
|
|
124
|
+
if (!committed) {
|
|
125
|
+
// Remove staged events
|
|
126
|
+
while (events.length > 0 && events[events.length - 1]!.position >= startPosition) {
|
|
127
|
+
events.pop()
|
|
128
|
+
}
|
|
129
|
+
nextPosition = startPosition
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async append(
|
|
136
|
+
newEvents: ReadonlyArray<EventMessage>,
|
|
137
|
+
condition?: AppendCondition,
|
|
138
|
+
): Promise<ConsistencyMarker> {
|
|
139
|
+
if (condition) {
|
|
140
|
+
const conflicting = events
|
|
141
|
+
.filter((entry) => entry.position > condition.marker.position)
|
|
142
|
+
.filter((entry) => matchesCriteria(entry.event, condition.criteria))
|
|
143
|
+
|
|
144
|
+
if (conflicting.length > 0) {
|
|
145
|
+
throw new AppendConditionError(
|
|
146
|
+
`Append condition violated: ${conflicting.length} conflicting event(s) ` +
|
|
147
|
+
`found after position ${condition.marker.position}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const event of newEvents) {
|
|
153
|
+
events.push({ position: nextPosition, event })
|
|
154
|
+
nextPosition++
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Notify open streams that new events are available
|
|
158
|
+
notifyListeners()
|
|
159
|
+
|
|
160
|
+
// Notify push-based subscribers with the actual events
|
|
161
|
+
for (const subscriber of eventSubscribers) {
|
|
162
|
+
try { await subscriber(newEvents) } catch { /* ignore subscriber errors */ }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return markerAt(nextPosition - 1n)
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
open(condition: StreamingCondition): MessageStream<SequencedEvent> {
|
|
169
|
+
let cursor = condition.position
|
|
170
|
+
const criteria = condition.criteria
|
|
171
|
+
let availableCallback: (() => void) | null = null
|
|
172
|
+
let closed = false
|
|
173
|
+
|
|
174
|
+
const listener = () => {
|
|
175
|
+
if (!closed && availableCallback) {
|
|
176
|
+
availableCallback()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
streamListeners.add(listener)
|
|
181
|
+
|
|
182
|
+
function findNext(): SequencedEvent | undefined {
|
|
183
|
+
if (closed) return undefined
|
|
184
|
+
for (const entry of events) {
|
|
185
|
+
if (entry.position < cursor) continue
|
|
186
|
+
if (criteria && !matchesCriteria(entry.event, criteria)) continue
|
|
187
|
+
return { sequence: entry.position, event: entry.event }
|
|
188
|
+
}
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return createMessageStream<SequencedEvent>({
|
|
193
|
+
next() {
|
|
194
|
+
const item = findNext()
|
|
195
|
+
if (item) {
|
|
196
|
+
cursor = item.sequence + 1n
|
|
197
|
+
}
|
|
198
|
+
return item
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
peek() {
|
|
202
|
+
return findNext()
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
hasNextAvailable() {
|
|
206
|
+
return findNext() !== undefined
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
setCallback(callback: () => void) {
|
|
210
|
+
availableCallback = callback
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
isCompleted() {
|
|
214
|
+
return closed
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
error() {
|
|
218
|
+
return undefined // In-memory store never errors
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
close() {
|
|
222
|
+
closed = true
|
|
223
|
+
availableCallback = null
|
|
224
|
+
streamListeners.delete(listener)
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
async getHeadPosition(): Promise<bigint> {
|
|
230
|
+
return nextPosition
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
async firstToken(): Promise<TrackingToken> {
|
|
234
|
+
return FIRST_TOKEN
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async latestToken(): Promise<TrackingToken> {
|
|
238
|
+
return globalSequenceToken(nextPosition)
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async publish(publishedEvents: ReadonlyArray<EventMessage>): Promise<void> {
|
|
242
|
+
// In the in-memory store, publish = append without condition
|
|
243
|
+
for (const event of publishedEvents) {
|
|
244
|
+
events.push({ position: nextPosition, event })
|
|
245
|
+
nextPosition++
|
|
246
|
+
}
|
|
247
|
+
notifyListeners()
|
|
248
|
+
for (const subscriber of eventSubscribers) {
|
|
249
|
+
try { await subscriber(publishedEvents) } catch { /* ignore */ }
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
subscribe(handler: (events: ReadonlyArray<EventMessage>) => Promise<void>): () => void {
|
|
254
|
+
eventSubscribers.add(handler)
|
|
255
|
+
return () => { eventSubscribers.delete(handler) }
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Thrown when an append condition is violated — optimistic concurrency failure.
|
|
262
|
+
*/
|
|
263
|
+
export class AppendConditionError extends Error {
|
|
264
|
+
constructor(message: string) {
|
|
265
|
+
super(message)
|
|
266
|
+
this.name = "AppendConditionError"
|
|
267
|
+
}
|
|
268
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ConsistencyMarker,
|
|
3
|
+
ORIGIN,
|
|
4
|
+
INFINITY,
|
|
5
|
+
MARKER_RESOURCE_KEY,
|
|
6
|
+
noMarker,
|
|
7
|
+
markerAt,
|
|
8
|
+
markerLowerBound,
|
|
9
|
+
markerUpperBound,
|
|
10
|
+
} from "./consistency-marker.js"
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
type SourcingCondition,
|
|
14
|
+
sourcingCondition,
|
|
15
|
+
} from "./sourcing-condition.js"
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
type AppendCondition,
|
|
19
|
+
appendCondition,
|
|
20
|
+
} from "./append-condition.js"
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
type EventStore,
|
|
24
|
+
type SourcingResult,
|
|
25
|
+
} from "./event-store.js"
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
type EventStoreTransaction,
|
|
29
|
+
createEventStoreTransaction,
|
|
30
|
+
} from "./event-store-transaction.js"
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
createInMemoryEventStore,
|
|
34
|
+
AppendConditionError,
|
|
35
|
+
} from "./in-memory-event-store.js"
|
|
36
|
+
|
|
37
|
+
export { type EventStorageEngine, type AppendTransaction } from "./event-storage-engine.js"
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
type TagResolver,
|
|
41
|
+
descriptorBasedTagResolver,
|
|
42
|
+
metadataBasedTagResolver,
|
|
43
|
+
multiTagResolver,
|
|
44
|
+
} from "./tag-resolver.js"
|
|
45
|
+
|
|
46
|
+
export { createEventSourcedRepository } from "./event-sourced-repository.js"
|
|
47
|
+
export type { EventSourcedRepositoryOptions } from "./event-sourced-repository.js"
|
|
48
|
+
|
|
49
|
+
export {
|
|
50
|
+
type SnapshotPolicy,
|
|
51
|
+
type EvolutionResult,
|
|
52
|
+
afterEvents,
|
|
53
|
+
whenSourcingTimeExceeds,
|
|
54
|
+
noSnapshotPolicy,
|
|
55
|
+
} from "./snapshot-policy.js"
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
type Snapshot,
|
|
59
|
+
type SnapshotStore,
|
|
60
|
+
createInMemorySnapshotStore,
|
|
61
|
+
} from "./snapshot-store.js"
|
|
62
|
+
|
|
63
|
+
export { createInterceptingEventStore } from "./intercepting-event-store.js"
|
|
64
|
+
|
|
65
|
+
// Module-level handler helpers (Plan 04-01 / HDL-02 / D-42)
|
|
66
|
+
export { load, STATE_MANAGER_KEY } from "./load.js"
|
|
67
|
+
export {
|
|
68
|
+
append,
|
|
69
|
+
BUFFERED_EVENTS_KEY,
|
|
70
|
+
SOURCING_INFOS_KEY,
|
|
71
|
+
STATE_CACHE_KEY,
|
|
72
|
+
STATE_MODULES_KEY,
|
|
73
|
+
} from "./append.js"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { EventMessage, DispatchInterceptor, StreamingCondition, MessageStream, SequencedEvent } from "@kronos-ts/messaging"
|
|
2
|
+
import type { EventStore } from "./event-store.js"
|
|
3
|
+
import type { AppendTransaction } from "./event-storage-engine.js"
|
|
4
|
+
import type { AppendCondition } from "./append-condition.js"
|
|
5
|
+
import type { ConsistencyMarker } from "./consistency-marker.js"
|
|
6
|
+
import type { SourcingCondition } from "./sourcing-condition.js"
|
|
7
|
+
import type { SourcingResult } from "./event-store.js"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An {@link EventStore} decorator that applies dispatch interceptors
|
|
11
|
+
* before events are appended or published.
|
|
12
|
+
*
|
|
13
|
+
* Read operations (source, open, getHeadPosition) pass through to
|
|
14
|
+
* the delegate without interception.
|
|
15
|
+
*/
|
|
16
|
+
export function createInterceptingEventStore(
|
|
17
|
+
delegate: EventStore,
|
|
18
|
+
dispatchInterceptors: ReadonlyArray<DispatchInterceptor<EventMessage>>,
|
|
19
|
+
): EventStore {
|
|
20
|
+
async function interceptEvents(
|
|
21
|
+
events: ReadonlyArray<EventMessage>,
|
|
22
|
+
): Promise<EventMessage[]> {
|
|
23
|
+
const intercepted: EventMessage[] = []
|
|
24
|
+
for (const event of events) {
|
|
25
|
+
let msg = event
|
|
26
|
+
for (const interceptor of dispatchInterceptors) {
|
|
27
|
+
msg = await interceptor(msg) as EventMessage
|
|
28
|
+
}
|
|
29
|
+
intercepted.push(msg)
|
|
30
|
+
}
|
|
31
|
+
return intercepted
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
// Read operations pass through
|
|
36
|
+
source(condition: SourcingCondition): Promise<SourcingResult> {
|
|
37
|
+
return delegate.source(condition)
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
// AppendEvents intercepts events before storage
|
|
41
|
+
async appendEvents(
|
|
42
|
+
events: ReadonlyArray<EventMessage>,
|
|
43
|
+
condition?: AppendCondition,
|
|
44
|
+
): Promise<AppendTransaction> {
|
|
45
|
+
const intercepted = await interceptEvents(events)
|
|
46
|
+
return delegate.appendEvents(intercepted, condition)
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async append(
|
|
50
|
+
events: ReadonlyArray<EventMessage>,
|
|
51
|
+
condition?: AppendCondition,
|
|
52
|
+
): Promise<ConsistencyMarker> {
|
|
53
|
+
const intercepted = await interceptEvents(events)
|
|
54
|
+
return delegate.append(intercepted, condition)
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
// Publish intercepts events before distribution
|
|
58
|
+
async publish(events: ReadonlyArray<EventMessage>): Promise<void> {
|
|
59
|
+
const intercepted = await interceptEvents(events)
|
|
60
|
+
return delegate.publish(intercepted)
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Stream/read pass through
|
|
64
|
+
open: (condition: StreamingCondition) => delegate.open(condition),
|
|
65
|
+
getHeadPosition: () => delegate.getHeadPosition(),
|
|
66
|
+
firstToken: () => delegate.firstToken(),
|
|
67
|
+
latestToken: () => delegate.latestToken(),
|
|
68
|
+
subscribe: (handler) => delegate.subscribe(handler),
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { resourceKey, type ResourceKey } from "@kronos-ts/common"
|
|
2
|
+
import {
|
|
3
|
+
processingStateStorage,
|
|
4
|
+
computeIfAbsent,
|
|
5
|
+
NoActiveUnitOfWork,
|
|
6
|
+
} from "@kronos-ts/messaging/processing-state"
|
|
7
|
+
import type { EventCriteria } from "@kronos-ts/messaging"
|
|
8
|
+
import { STATE_CACHE_KEY, STATE_MODULES_KEY, SOURCING_INFOS_KEY } from "./append.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load event-sourced state for a module within the active unit of work.
|
|
12
|
+
*
|
|
13
|
+
* The first signature matches a `StateModule`-shaped object structurally
|
|
14
|
+
* (without importing `@kronos-ts/modelling`, which would invert the
|
|
15
|
+
* dependency direction) so both the id and state types are inferred.
|
|
16
|
+
*/
|
|
17
|
+
export interface LoadFunction {
|
|
18
|
+
<Id, S>(module: { kind: "state-module"; name: string; create: (id: Id) => S }, id: Id): Promise<S>
|
|
19
|
+
<S>(module: { name: string }, id: unknown): Promise<S>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// State manager interface — minimal shape needed by load
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
type StateManagerLike = {
|
|
27
|
+
load: (
|
|
28
|
+
module: any,
|
|
29
|
+
id: any,
|
|
30
|
+
) => Promise<{
|
|
31
|
+
state: any
|
|
32
|
+
sourcingInfo: { criteria: EventCriteria; markerPosition: bigint }
|
|
33
|
+
}>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resource key for the state manager component.
|
|
38
|
+
* Written by handling modules + processors at handler-invocation entry (D-44).
|
|
39
|
+
*/
|
|
40
|
+
export const STATE_MANAGER_KEY: ResourceKey<StateManagerLike> = resourceKey("stateManager")
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Plan 04-01 (HDL-02 / D-42): module-level load.
|
|
44
|
+
*
|
|
45
|
+
* Read-only — NOT phase-guarded per D-43. Throws NoActiveUnitOfWork outside
|
|
46
|
+
* a UoW. Caches state within the UoW (duplicate load() calls for the
|
|
47
|
+
* same module-id pair return the cached promise without re-querying the store).
|
|
48
|
+
*/
|
|
49
|
+
export const load: LoadFunction = (async <S>(module: { name: string }, id: unknown): Promise<S> => {
|
|
50
|
+
const state = processingStateStorage.getStore()
|
|
51
|
+
if (state === undefined) throw new NoActiveUnitOfWork()
|
|
52
|
+
const stateManager = state.resources.get(STATE_MANAGER_KEY.symbol) as StateManagerLike | undefined
|
|
53
|
+
if (!stateManager) throw new Error("No state manager configured")
|
|
54
|
+
|
|
55
|
+
const cache = computeIfAbsent(STATE_CACHE_KEY, () => new Map())
|
|
56
|
+
const cacheKey = `${module.name}:${String(id)}`
|
|
57
|
+
if (!cache.has(cacheKey)) {
|
|
58
|
+
cache.set(cacheKey, stateManager.load(module, id))
|
|
59
|
+
const modules = computeIfAbsent(STATE_MODULES_KEY, () => new Map())
|
|
60
|
+
modules.set(cacheKey, { module, id })
|
|
61
|
+
}
|
|
62
|
+
const result = await cache.get(cacheKey)!
|
|
63
|
+
const loadResult = result as {
|
|
64
|
+
state: any
|
|
65
|
+
sourcingInfo: { criteria: EventCriteria; markerPosition: bigint }
|
|
66
|
+
}
|
|
67
|
+
const infos = computeIfAbsent(SOURCING_INFOS_KEY, () => [])
|
|
68
|
+
infos.push(loadResult.sourcingInfo)
|
|
69
|
+
return loadResult.state as S
|
|
70
|
+
}) as LoadFunction
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metrics collected during state evolution (event sourcing).
|
|
3
|
+
* Used by snapshot policies to decide if a new snapshot should be created.
|
|
4
|
+
*
|
|
5
|
+
*/
|
|
6
|
+
export interface EvolutionResult {
|
|
7
|
+
/** Number of events applied to reach the current state. */
|
|
8
|
+
readonly eventsApplied: number
|
|
9
|
+
/** Time spent sourcing in milliseconds. */
|
|
10
|
+
readonly sourcingTimeMs: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Determines when snapshots should be created for a state.
|
|
15
|
+
*
|
|
16
|
+
* Policies are composable via `or()` — if any policy triggers,
|
|
17
|
+
* a snapshot is created.
|
|
18
|
+
*
|
|
19
|
+
*/
|
|
20
|
+
export interface SnapshotPolicy {
|
|
21
|
+
/**
|
|
22
|
+
* Whether a snapshot should be created based on the evolution result.
|
|
23
|
+
*/
|
|
24
|
+
shouldSnapshot(result: EvolutionResult): boolean
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Combine this policy with another. The combined policy triggers
|
|
28
|
+
* if either policy triggers.
|
|
29
|
+
*/
|
|
30
|
+
or(other: SnapshotPolicy): SnapshotPolicy
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a snapshot policy that triggers after N events have been applied.
|
|
35
|
+
*
|
|
36
|
+
*/
|
|
37
|
+
export function afterEvents(threshold: number): SnapshotPolicy {
|
|
38
|
+
return createPolicy((result) => result.eventsApplied > threshold)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Creates a snapshot policy that triggers when sourcing time exceeds
|
|
43
|
+
* the given threshold in milliseconds.
|
|
44
|
+
*
|
|
45
|
+
*/
|
|
46
|
+
export function whenSourcingTimeExceeds(thresholdMs: number): SnapshotPolicy {
|
|
47
|
+
return createPolicy((result) => result.sourcingTimeMs >= thresholdMs)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A policy that never triggers. No snapshots will be created.
|
|
52
|
+
*/
|
|
53
|
+
export function noSnapshotPolicy(): SnapshotPolicy {
|
|
54
|
+
return createPolicy(() => false)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Internal helper
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
function createPolicy(
|
|
62
|
+
predicate: (result: EvolutionResult) => boolean,
|
|
63
|
+
): SnapshotPolicy {
|
|
64
|
+
const policy: SnapshotPolicy = {
|
|
65
|
+
shouldSnapshot: predicate,
|
|
66
|
+
or(other: SnapshotPolicy): SnapshotPolicy {
|
|
67
|
+
return createPolicy(
|
|
68
|
+
(result) => predicate(result) || other.shouldSnapshot(result),
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
return policy
|
|
73
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A point-in-time capture of a state value.
|
|
3
|
+
*
|
|
4
|
+
*/
|
|
5
|
+
export interface Snapshot {
|
|
6
|
+
/** The global position in the event stream at snapshot time. */
|
|
7
|
+
readonly position: bigint
|
|
8
|
+
/** The serialized state. */
|
|
9
|
+
readonly payload: unknown
|
|
10
|
+
/** When the snapshot was created (epoch ms). */
|
|
11
|
+
readonly timestamp: number
|
|
12
|
+
/** Optional metadata associated with the snapshot. */
|
|
13
|
+
readonly metadata: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stores and retrieves state snapshots.
|
|
18
|
+
*
|
|
19
|
+
* Separate from the event store — snapshots are an optimization, not
|
|
20
|
+
* part of the event stream.
|
|
21
|
+
*
|
|
22
|
+
*/
|
|
23
|
+
export interface SnapshotStore {
|
|
24
|
+
/**
|
|
25
|
+
* Store a snapshot for a state value.
|
|
26
|
+
* Replaces any existing snapshot for the same state and id.
|
|
27
|
+
*/
|
|
28
|
+
store(stateName: string, id: unknown, snapshot: Snapshot): Promise<void>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the most recent snapshot for a state value.
|
|
32
|
+
* Returns undefined if no snapshot exists.
|
|
33
|
+
*/
|
|
34
|
+
load(stateName: string, id: unknown): Promise<Snapshot | undefined>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Delete all snapshots for a state value.
|
|
38
|
+
*/
|
|
39
|
+
deleteSnapshots(stateName: string, id: unknown): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* In-memory snapshot store for testing and standalone usage.
|
|
44
|
+
*/
|
|
45
|
+
export function createInMemorySnapshotStore(): SnapshotStore {
|
|
46
|
+
// Key: "stateName:id"
|
|
47
|
+
const snapshots = new Map<string, Snapshot>()
|
|
48
|
+
|
|
49
|
+
function key(stateName: string, id: unknown): string {
|
|
50
|
+
const idStr = typeof id === "object" && id !== null ? JSON.stringify(id) : String(id)
|
|
51
|
+
return `${stateName}:${idStr}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
async store(stateName, id, snapshot) {
|
|
56
|
+
snapshots.set(key(stateName, id), snapshot)
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async load(stateName, id) {
|
|
60
|
+
return snapshots.get(key(stateName, id))
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async deleteSnapshots(stateName, id) {
|
|
64
|
+
snapshots.delete(key(stateName, id))
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { EventCriteria } from "@kronos-ts/messaging"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defines which events to source from the event store.
|
|
5
|
+
* Combines criteria (what to match) with an optional start position.
|
|
6
|
+
*/
|
|
7
|
+
export interface SourcingCondition {
|
|
8
|
+
readonly criteria: EventCriteria
|
|
9
|
+
readonly start?: bigint
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create a sourcing condition from criteria and an optional start position.
|
|
14
|
+
*/
|
|
15
|
+
export function sourcingCondition(criteria: EventCriteria, start?: bigint): SourcingCondition {
|
|
16
|
+
return start !== undefined ? { criteria, start } : { criteria }
|
|
17
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Tag } from "@kronos-ts/common"
|
|
2
|
+
import type { EventMessage } from "@kronos-ts/messaging"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves tags from an event message. Tags are metadata markers attached
|
|
6
|
+
* to events for filtering, categorization, and criteria-based sourcing.
|
|
7
|
+
*
|
|
8
|
+
* By default, tags are derived from the event descriptor's `tags` function
|
|
9
|
+
* at event creation time. The TagResolver runs before storage and can enrich
|
|
10
|
+
* events with additional tags from metadata, context, etc.
|
|
11
|
+
*/
|
|
12
|
+
export interface TagResolver {
|
|
13
|
+
resolve(event: EventMessage): Tag[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default tag resolver — passes through tags already on the event.
|
|
18
|
+
*
|
|
19
|
+
* Events are created with descriptor-derived tags. This resolver simply
|
|
20
|
+
* returns those existing tags unchanged.
|
|
21
|
+
*/
|
|
22
|
+
export function descriptorBasedTagResolver(): TagResolver {
|
|
23
|
+
return {
|
|
24
|
+
resolve(event: EventMessage): Tag[] {
|
|
25
|
+
return [...event.tags]
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolves additional tags from event metadata. For each configured key,
|
|
32
|
+
* if the metadata contains that key, a tag is created.
|
|
33
|
+
*/
|
|
34
|
+
export function metadataBasedTagResolver(...metadataKeys: string[]): TagResolver {
|
|
35
|
+
return {
|
|
36
|
+
resolve(event: EventMessage): Tag[] {
|
|
37
|
+
const tags: Tag[] = []
|
|
38
|
+
for (const key of metadataKeys) {
|
|
39
|
+
const value = event.metadata[key]
|
|
40
|
+
if (value != null) {
|
|
41
|
+
tags.push({ key, value: String(value) })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return tags
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Combines multiple tag resolvers. Tags from all resolvers are merged.
|
|
51
|
+
*/
|
|
52
|
+
export function multiTagResolver(...resolvers: TagResolver[]): TagResolver {
|
|
53
|
+
return {
|
|
54
|
+
resolve(event: EventMessage): Tag[] {
|
|
55
|
+
const tags: Tag[] = []
|
|
56
|
+
for (const resolver of resolvers) {
|
|
57
|
+
tags.push(...resolver.resolve(event))
|
|
58
|
+
}
|
|
59
|
+
return tags
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|