@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.
Files changed (76) hide show
  1. package/dist/append-condition.d.ts +20 -0
  2. package/dist/append-condition.d.ts.map +1 -0
  3. package/dist/append-condition.js +7 -0
  4. package/dist/append-condition.js.map +1 -0
  5. package/dist/append.d.ts +33 -0
  6. package/dist/append.d.ts.map +1 -0
  7. package/dist/append.js +65 -0
  8. package/dist/append.js.map +1 -0
  9. package/dist/consistency-marker.d.ts +28 -0
  10. package/dist/consistency-marker.d.ts.map +1 -0
  11. package/dist/consistency-marker.js +28 -0
  12. package/dist/consistency-marker.js.map +1 -0
  13. package/dist/event-sourced-repository.d.ts +23 -0
  14. package/dist/event-sourced-repository.d.ts.map +1 -0
  15. package/dist/event-sourced-repository.js +105 -0
  16. package/dist/event-sourced-repository.js.map +1 -0
  17. package/dist/event-storage-engine.d.ts +60 -0
  18. package/dist/event-storage-engine.d.ts.map +1 -0
  19. package/dist/event-storage-engine.js +2 -0
  20. package/dist/event-storage-engine.js.map +1 -0
  21. package/dist/event-store-transaction.d.ts +31 -0
  22. package/dist/event-store-transaction.d.ts.map +1 -0
  23. package/dist/event-store-transaction.js +28 -0
  24. package/dist/event-store-transaction.js.map +1 -0
  25. package/dist/event-store.d.ts +26 -0
  26. package/dist/event-store.d.ts.map +1 -0
  27. package/dist/event-store.js +2 -0
  28. package/dist/event-store.js.map +1 -0
  29. package/dist/in-memory-event-store.d.ts +14 -0
  30. package/dist/in-memory-event-store.d.ts.map +1 -0
  31. package/dist/in-memory-event-store.js +225 -0
  32. package/dist/in-memory-event-store.js.map +1 -0
  33. package/dist/index.d.ts +16 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +16 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/intercepting-event-store.d.ts +11 -0
  38. package/dist/intercepting-event-store.d.ts.map +1 -0
  39. package/dist/intercepting-event-store.js +47 -0
  40. package/dist/intercepting-event-store.js.map +1 -0
  41. package/dist/load.d.ts +43 -0
  42. package/dist/load.d.ts.map +1 -0
  43. package/dist/load.js +36 -0
  44. package/dist/load.js.map +1 -0
  45. package/dist/snapshot-policy.d.ts +45 -0
  46. package/dist/snapshot-policy.d.ts.map +1 -0
  47. package/dist/snapshot-policy.js +34 -0
  48. package/dist/snapshot-policy.js.map +1 -0
  49. package/dist/snapshot-store.d.ts +42 -0
  50. package/dist/snapshot-store.d.ts.map +1 -0
  51. package/dist/snapshot-store.js +23 -0
  52. package/dist/snapshot-store.js.map +1 -0
  53. package/dist/sourcing-condition.d.ts +14 -0
  54. package/dist/sourcing-condition.d.ts.map +1 -0
  55. package/dist/sourcing-condition.js +7 -0
  56. package/dist/sourcing-condition.js.map +1 -0
  57. package/dist/tag-resolver.d.ts +30 -0
  58. package/dist/tag-resolver.d.ts.map +1 -0
  59. package/dist/tag-resolver.js +46 -0
  60. package/dist/tag-resolver.js.map +1 -0
  61. package/package.json +58 -0
  62. package/src/append-condition.ts +23 -0
  63. package/src/append.ts +99 -0
  64. package/src/consistency-marker.ts +43 -0
  65. package/src/event-sourced-repository.ts +141 -0
  66. package/src/event-storage-engine.ts +69 -0
  67. package/src/event-store-transaction.ts +58 -0
  68. package/src/event-store.ts +26 -0
  69. package/src/in-memory-event-store.ts +268 -0
  70. package/src/index.ts +73 -0
  71. package/src/intercepting-event-store.ts +70 -0
  72. package/src/load.ts +70 -0
  73. package/src/snapshot-policy.ts +73 -0
  74. package/src/snapshot-store.ts +67 -0
  75. package/src/sourcing-condition.ts +17 -0
  76. 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
+ }