@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,30 @@
|
|
|
1
|
+
import type { Tag } from "@kronos-ts/common";
|
|
2
|
+
import type { EventMessage } from "@kronos-ts/messaging";
|
|
3
|
+
/**
|
|
4
|
+
* Resolves tags from an event message. Tags are metadata markers attached
|
|
5
|
+
* to events for filtering, categorization, and criteria-based sourcing.
|
|
6
|
+
*
|
|
7
|
+
* By default, tags are derived from the event descriptor's `tags` function
|
|
8
|
+
* at event creation time. The TagResolver runs before storage and can enrich
|
|
9
|
+
* events with additional tags from metadata, context, etc.
|
|
10
|
+
*/
|
|
11
|
+
export interface TagResolver {
|
|
12
|
+
resolve(event: EventMessage): Tag[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Default tag resolver — passes through tags already on the event.
|
|
16
|
+
*
|
|
17
|
+
* Events are created with descriptor-derived tags. This resolver simply
|
|
18
|
+
* returns those existing tags unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export declare function descriptorBasedTagResolver(): TagResolver;
|
|
21
|
+
/**
|
|
22
|
+
* Resolves additional tags from event metadata. For each configured key,
|
|
23
|
+
* if the metadata contains that key, a tag is created.
|
|
24
|
+
*/
|
|
25
|
+
export declare function metadataBasedTagResolver(...metadataKeys: string[]): TagResolver;
|
|
26
|
+
/**
|
|
27
|
+
* Combines multiple tag resolvers. Tags from all resolvers are merged.
|
|
28
|
+
*/
|
|
29
|
+
export declare function multiTagResolver(...resolvers: TagResolver[]): TagResolver;
|
|
30
|
+
//# sourceMappingURL=tag-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tag-resolver.d.ts","sourceRoot":"","sources":["../src/tag-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,mBAAmB,CAAA;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AAExD;;;;;;;GAOG;AACH,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,KAAK,EAAE,YAAY,GAAG,GAAG,EAAE,CAAA;CACpC;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,IAAI,WAAW,CAMxD;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,YAAY,EAAE,MAAM,EAAE,GAAG,WAAW,CAa/E;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,SAAS,EAAE,WAAW,EAAE,GAAG,WAAW,CAUzE"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default tag resolver — passes through tags already on the event.
|
|
3
|
+
*
|
|
4
|
+
* Events are created with descriptor-derived tags. This resolver simply
|
|
5
|
+
* returns those existing tags unchanged.
|
|
6
|
+
*/
|
|
7
|
+
export function descriptorBasedTagResolver() {
|
|
8
|
+
return {
|
|
9
|
+
resolve(event) {
|
|
10
|
+
return [...event.tags];
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolves additional tags from event metadata. For each configured key,
|
|
16
|
+
* if the metadata contains that key, a tag is created.
|
|
17
|
+
*/
|
|
18
|
+
export function metadataBasedTagResolver(...metadataKeys) {
|
|
19
|
+
return {
|
|
20
|
+
resolve(event) {
|
|
21
|
+
const tags = [];
|
|
22
|
+
for (const key of metadataKeys) {
|
|
23
|
+
const value = event.metadata[key];
|
|
24
|
+
if (value != null) {
|
|
25
|
+
tags.push({ key, value: String(value) });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return tags;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Combines multiple tag resolvers. Tags from all resolvers are merged.
|
|
34
|
+
*/
|
|
35
|
+
export function multiTagResolver(...resolvers) {
|
|
36
|
+
return {
|
|
37
|
+
resolve(event) {
|
|
38
|
+
const tags = [];
|
|
39
|
+
for (const resolver of resolvers) {
|
|
40
|
+
tags.push(...resolver.resolve(event));
|
|
41
|
+
}
|
|
42
|
+
return tags;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=tag-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tag-resolver.js","sourceRoot":"","sources":["../src/tag-resolver.ts"],"names":[],"mappings":"AAeA;;;;;GAKG;AACH,MAAM,UAAU,0BAA0B;IACxC,OAAO;QACL,OAAO,CAAC,KAAmB;YACzB,OAAO,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,CAAA;QACxB,CAAC;KACF,CAAA;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,GAAG,YAAsB;IAChE,OAAO;QACL,OAAO,CAAC,KAAmB;YACzB,MAAM,IAAI,GAAU,EAAE,CAAA;YACtB,KAAK,MAAM,GAAG,IAAI,YAAY,EAAE,CAAC;gBAC/B,MAAM,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA;gBACjC,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;oBAClB,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;gBAC1C,CAAC;YACH,CAAC;YACD,OAAO,IAAI,CAAA;QACb,CAAC;KACF,CAAA;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAG,SAAwB;IAC1D,OAAO;QACL,OAAO,CAAC,KAAmB;YACzB,MAAM,IAAI,GAAU,EAAE,CAAA;YACtB,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;gBACjC,IAAI,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAA;YACvC,CAAC;YACD,OAAO,IAAI,CAAA;QACb,CAAC;KACF,CAAA;AACH,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kronos-ts/eventsourcing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Event sourcing for Kronos — dynamic-consistency-boundary event store with load/append.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "Theo Emanuelsson",
|
|
8
|
+
"homepage": "https://github.com/KronosDB/kronos-ts/tree/main/packages/eventsourcing#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/KronosDB/kronos-ts.git",
|
|
12
|
+
"directory": "packages/eventsourcing"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/KronosDB/kronos-ts/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"kronos",
|
|
19
|
+
"event-sourcing",
|
|
20
|
+
"cqrs",
|
|
21
|
+
"dcb",
|
|
22
|
+
"typescript"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"main": "src/index.ts",
|
|
26
|
+
"types": "src/index.ts",
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"src",
|
|
30
|
+
"!src/**/__tests__",
|
|
31
|
+
"!src/**/*.test.ts",
|
|
32
|
+
"!src/**/*.bench.ts"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"main": "./dist/index.js",
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"exports": {
|
|
43
|
+
".": {
|
|
44
|
+
"types": "./dist/index.d.ts",
|
|
45
|
+
"default": "./dist/index.js"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@kronos-ts/common": "workspace:*",
|
|
51
|
+
"@kronos-ts/messaging": "workspace:*",
|
|
52
|
+
"@kronos-ts/modelling": "workspace:*",
|
|
53
|
+
"zod": "^4.3.6"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@kronos-ts/app": "workspace:*"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EventCriteria } from "@kronos-ts/messaging"
|
|
2
|
+
import type { ConsistencyMarker } from "./consistency-marker.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Defines the consistency boundary for appending events.
|
|
6
|
+
*
|
|
7
|
+
* By default, the append condition matches the sourcing condition —
|
|
8
|
+
* guaranteeing that no conflicting events were appended since the state was loaded.
|
|
9
|
+
*
|
|
10
|
+
* Can be overridden per command handler for cases where less strict consistency
|
|
11
|
+
* is valid (e.g. a bank debit that doesn't conflict with credits).
|
|
12
|
+
*/
|
|
13
|
+
export interface AppendCondition {
|
|
14
|
+
readonly criteria: EventCriteria
|
|
15
|
+
readonly marker: ConsistencyMarker
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create an append condition from criteria and a consistency marker.
|
|
20
|
+
*/
|
|
21
|
+
export function appendCondition(criteria: EventCriteria, marker: ConsistencyMarker): AppendCondition {
|
|
22
|
+
return { criteria, marker }
|
|
23
|
+
}
|
package/src/append.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resourceKey,
|
|
3
|
+
qualifiedNameToString,
|
|
4
|
+
generateIdentifier,
|
|
5
|
+
type Metadata,
|
|
6
|
+
type ResourceKey,
|
|
7
|
+
} from "@kronos-ts/common"
|
|
8
|
+
import {
|
|
9
|
+
getResource,
|
|
10
|
+
computeIfAbsent,
|
|
11
|
+
requireInvocationPhase,
|
|
12
|
+
} from "@kronos-ts/messaging/processing-state"
|
|
13
|
+
import type { z } from "zod"
|
|
14
|
+
import type { EventDescriptor, EventMessage, EventCriteria } from "@kronos-ts/messaging"
|
|
15
|
+
|
|
16
|
+
/** Append events to the active unit of work, buffered until commit. */
|
|
17
|
+
export interface AppendFunction {
|
|
18
|
+
<P extends z.ZodType>(event: EventDescriptor<P>, payload: z.infer<P>): void
|
|
19
|
+
<P extends z.ZodType>(event: EventDescriptor<P>, payload: z.infer<P>, metadata: Metadata): void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Resource keys (owned by append — open-question #1 resolved: keys live with
|
|
24
|
+
// the helper that writes them)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Buffered events waiting to be flushed at PREPARE_COMMIT. */
|
|
28
|
+
export const BUFFERED_EVENTS_KEY: ResourceKey<EventMessage[]> = resourceKey("bufferedEvents")
|
|
29
|
+
|
|
30
|
+
/** Sourcing info from load() calls, used to build append condition. */
|
|
31
|
+
export const SOURCING_INFOS_KEY: ResourceKey<Array<{ criteria: EventCriteria; markerPosition: bigint }>> =
|
|
32
|
+
resourceKey("sourcingInfos")
|
|
33
|
+
|
|
34
|
+
/** State cache: prevents duplicate load() calls within same UnitOfWork. */
|
|
35
|
+
export const STATE_CACHE_KEY: ResourceKey<Map<string, Promise<unknown>>> = resourceKey("stateCache")
|
|
36
|
+
|
|
37
|
+
/** State module references keyed by cache key, used to apply evolvers on append. */
|
|
38
|
+
export const STATE_MODULES_KEY: ResourceKey<Map<string, { module: any; id: unknown }>> =
|
|
39
|
+
resourceKey("stateModules")
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Plan 04-01 (HDL-02 / D-42): module-level append.
|
|
43
|
+
*
|
|
44
|
+
* Throws NoActiveUnitOfWork outside a UoW (D-43 fail-fast on no-UoW).
|
|
45
|
+
* Throws WrongUoWPhase outside INVOCATION phase (D-43 mutator guard).
|
|
46
|
+
*
|
|
47
|
+
* Buffers events in BUFFERED_EVENTS_KEY; updates cached state via
|
|
48
|
+
* matching evolvers (same logic as command-handling-module.ts appendFn).
|
|
49
|
+
*/
|
|
50
|
+
export const append: AppendFunction = ((
|
|
51
|
+
eventDescriptor: EventDescriptor<any>,
|
|
52
|
+
eventPayload: unknown,
|
|
53
|
+
eventMetadata?: Metadata,
|
|
54
|
+
) => {
|
|
55
|
+
const state = requireInvocationPhase() // D-43 mutator guard
|
|
56
|
+
const events = computeIfAbsent(BUFFERED_EVENTS_KEY, () => [])
|
|
57
|
+
const tags = eventDescriptor.tags ? eventDescriptor.tags(eventPayload) : []
|
|
58
|
+
const eventMessage: EventMessage = {
|
|
59
|
+
identifier: generateIdentifier(),
|
|
60
|
+
name: eventDescriptor.name,
|
|
61
|
+
version: eventDescriptor.version,
|
|
62
|
+
payload: eventPayload,
|
|
63
|
+
metadata: eventMetadata ?? state.metadata, // fallback to UoW metadata when caller omits
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
tags,
|
|
66
|
+
}
|
|
67
|
+
events.push(eventMessage)
|
|
68
|
+
|
|
69
|
+
// Update cached state by applying matching evolvers.
|
|
70
|
+
// Verbatim copy of command-handling-module.ts:101-123 logic — moved here so
|
|
71
|
+
// both command-handling-module (via delegation) and direct module-level callers
|
|
72
|
+
// get identical behaviour.
|
|
73
|
+
const cache = getResource(STATE_CACHE_KEY)
|
|
74
|
+
const modules = getResource(STATE_MODULES_KEY)
|
|
75
|
+
if (cache && modules) {
|
|
76
|
+
const eventType = qualifiedNameToString(eventDescriptor.name)
|
|
77
|
+
for (const [cacheKey, { module, id }] of modules) {
|
|
78
|
+
const cachedPromise = cache.get(cacheKey)
|
|
79
|
+
if (!cachedPromise) continue
|
|
80
|
+
const evolvers = (module as any).evolvers as ReadonlyArray<{
|
|
81
|
+
descriptor: { name: any }
|
|
82
|
+
evolve: (s: any, e: any, id: any) => any
|
|
83
|
+
}> | undefined
|
|
84
|
+
if (!evolvers) continue
|
|
85
|
+
for (const evolver of evolvers) {
|
|
86
|
+
if (qualifiedNameToString(evolver.descriptor.name) === eventType) {
|
|
87
|
+
cache.set(
|
|
88
|
+
cacheKey,
|
|
89
|
+
cachedPromise.then((result: any) => ({
|
|
90
|
+
...result,
|
|
91
|
+
state: evolver.evolve(result.state, eventPayload, id),
|
|
92
|
+
})),
|
|
93
|
+
)
|
|
94
|
+
break
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}) as AppendFunction
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { resourceKey, type ResourceKey } from "@kronos-ts/common"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An opaque marker representing a position in the event store.
|
|
5
|
+
* Used by append conditions to guarantee consistency —
|
|
6
|
+
* "no conflicting events were appended since this marker."
|
|
7
|
+
*/
|
|
8
|
+
export interface ConsistencyMarker {
|
|
9
|
+
readonly position: bigint
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Marker representing the origin (before any events). */
|
|
13
|
+
export const ORIGIN: ConsistencyMarker = { position: -1n }
|
|
14
|
+
|
|
15
|
+
/** Marker representing infinity (no consistency check needed). */
|
|
16
|
+
export const INFINITY: ConsistencyMarker = { position: BigInt(Number.MAX_SAFE_INTEGER) }
|
|
17
|
+
|
|
18
|
+
/** Resource key for storing a ConsistencyMarker in a ProcessingContext. */
|
|
19
|
+
export const MARKER_RESOURCE_KEY: ResourceKey<ConsistencyMarker> = resourceKey("consistencyMarker")
|
|
20
|
+
|
|
21
|
+
export function noMarker(): ConsistencyMarker {
|
|
22
|
+
return ORIGIN
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function markerAt(position: bigint): ConsistencyMarker {
|
|
26
|
+
return { position }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the lower bound of two markers — the most restrictive position.
|
|
31
|
+
* When checking consistency across multiple sourcing operations,
|
|
32
|
+
* use the lower bound to ensure no events are missed.
|
|
33
|
+
*/
|
|
34
|
+
export function markerLowerBound(a: ConsistencyMarker, b: ConsistencyMarker): ConsistencyMarker {
|
|
35
|
+
return a.position < b.position ? a : b
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the upper bound of two markers — the least restrictive position.
|
|
40
|
+
*/
|
|
41
|
+
export function markerUpperBound(a: ConsistencyMarker, b: ConsistencyMarker): ConsistencyMarker {
|
|
42
|
+
return a.position > b.position ? a : b
|
|
43
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { qualifiedNameToString } from "@kronos-ts/common"
|
|
2
|
+
import type { EventMessage } from "@kronos-ts/messaging"
|
|
3
|
+
import type { StateModule, StateRepository, LoadResult } from "@kronos-ts/modelling"
|
|
4
|
+
import type { EventStore } from "./event-store.js"
|
|
5
|
+
import { sourcingCondition } from "./sourcing-condition.js"
|
|
6
|
+
import type { SnapshotStore, Snapshot } from "./snapshot-store.js"
|
|
7
|
+
import type { SnapshotPolicy, EvolutionResult } from "./snapshot-policy.js"
|
|
8
|
+
|
|
9
|
+
export interface EventSourcedRepositoryOptions<Id, S> {
|
|
10
|
+
state: StateModule<Id, S>
|
|
11
|
+
eventStore: EventStore
|
|
12
|
+
snapshotStore?: SnapshotStore
|
|
13
|
+
snapshotPolicy?: SnapshotPolicy
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a repository for a state module sourced from events.
|
|
18
|
+
*
|
|
19
|
+
* When `load(id)` is called, the repository:
|
|
20
|
+
* 1. Checks the snapshot store for a cached state (if configured)
|
|
21
|
+
* 2. Resolves the sourcing criteria from the state module + id
|
|
22
|
+
* 3. Sources matching events from the event store (from snapshot position if available)
|
|
23
|
+
* 4. Starts from snapshot state (or `create()`) and folds events through matching evolvers
|
|
24
|
+
* 5. Optionally creates a new snapshot if the policy triggers
|
|
25
|
+
* 6. Returns the state AND sourcing info (criteria + marker)
|
|
26
|
+
*/
|
|
27
|
+
export function createEventSourcedRepository<Id, S>(
|
|
28
|
+
module: StateModule<Id, S>,
|
|
29
|
+
eventStore: EventStore,
|
|
30
|
+
snapshotStore?: SnapshotStore,
|
|
31
|
+
snapshotPolicy?: SnapshotPolicy,
|
|
32
|
+
): StateRepository<Id, S> {
|
|
33
|
+
async function doLoad(id: Id): Promise<LoadResult<S>> {
|
|
34
|
+
const startTime = performance.now()
|
|
35
|
+
const criteria = module.criteria(id)
|
|
36
|
+
|
|
37
|
+
// Try to load snapshot
|
|
38
|
+
let state = module.create(id)
|
|
39
|
+
let startPosition: bigint | undefined
|
|
40
|
+
let snapshot: Snapshot | undefined
|
|
41
|
+
|
|
42
|
+
if (snapshotStore) {
|
|
43
|
+
try {
|
|
44
|
+
snapshot = await snapshotStore.load(module.name, id)
|
|
45
|
+
if (snapshot) {
|
|
46
|
+
state = snapshot.payload as S
|
|
47
|
+
// Source events AFTER the snapshot position
|
|
48
|
+
startPosition = snapshot.position + 1n
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.warn(`Failed to load snapshot for ${module.name}:${String(id)}, falling back to full replay:`, err)
|
|
52
|
+
// Fall back to full replay from the beginning
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const condition = sourcingCondition(criteria, startPosition)
|
|
57
|
+
const { events, marker } = await eventStore.source(condition)
|
|
58
|
+
|
|
59
|
+
const lifecycle = module.lifecycle
|
|
60
|
+
let isFirstEvent = !snapshot // first event only if no snapshot
|
|
61
|
+
let wasDeleted = lifecycle?.isDeleted?.(state) ?? false
|
|
62
|
+
|
|
63
|
+
let eventsApplied = 0
|
|
64
|
+
for (const event of events) {
|
|
65
|
+
const previousState = state
|
|
66
|
+
state = await applyEvent(module, state, event, id)
|
|
67
|
+
eventsApplied++
|
|
68
|
+
|
|
69
|
+
// Lifecycle hooks
|
|
70
|
+
if (lifecycle && state !== previousState) {
|
|
71
|
+
// onCreate: first event transitions from initial state
|
|
72
|
+
if (isFirstEvent && eventsApplied === 1) {
|
|
73
|
+
await lifecycle.onCreate?.(state, id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// onStateChange: after each evolving event
|
|
77
|
+
await lifecycle.onStateChange?.(previousState, state, event, id)
|
|
78
|
+
|
|
79
|
+
// onDelete: when isDeleted transitions from false to true
|
|
80
|
+
if (lifecycle.isDeleted) {
|
|
81
|
+
const nowDeleted = lifecycle.isDeleted(state)
|
|
82
|
+
if (nowDeleted && !wasDeleted) {
|
|
83
|
+
await lifecycle.onDelete?.(state, id)
|
|
84
|
+
}
|
|
85
|
+
wasDeleted = nowDeleted
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sourcingTimeMs = performance.now() - startTime
|
|
91
|
+
|
|
92
|
+
// Check if we should create a new snapshot (fire-and-forget)
|
|
93
|
+
if (snapshotStore && snapshotPolicy && eventsApplied > 0) {
|
|
94
|
+
const result: EvolutionResult = { eventsApplied, sourcingTimeMs }
|
|
95
|
+
if (snapshotPolicy.shouldSnapshot(result)) {
|
|
96
|
+
snapshotStore
|
|
97
|
+
.store(module.name, id, {
|
|
98
|
+
position: marker.position,
|
|
99
|
+
payload: state,
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
metadata: {},
|
|
102
|
+
})
|
|
103
|
+
.catch((err) => {
|
|
104
|
+
console.warn(`Failed to store snapshot for ${module.name}:${String(id)}:`, err)
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
state,
|
|
111
|
+
sourcingInfo: {
|
|
112
|
+
criteria,
|
|
113
|
+
markerPosition: marker.position,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
stateName: module.name,
|
|
120
|
+
load: doLoad,
|
|
121
|
+
loadOrCreate: doLoad, // Same implementation — create() always provides initial state
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function applyEvent<Id, S>(
|
|
126
|
+
module: StateModule<Id, S>,
|
|
127
|
+
state: S,
|
|
128
|
+
event: EventMessage,
|
|
129
|
+
id: Id,
|
|
130
|
+
): Promise<S> {
|
|
131
|
+
const eventType = qualifiedNameToString(event.name)
|
|
132
|
+
|
|
133
|
+
for (const evolver of module.evolvers) {
|
|
134
|
+
const evolverType = qualifiedNameToString(evolver.descriptor.name)
|
|
135
|
+
if (evolverType === eventType) {
|
|
136
|
+
return await evolver.evolve(state, event.payload, id)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return state
|
|
141
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { EventMessage, StreamableEventSource } from "@kronos-ts/messaging"
|
|
2
|
+
import type { SourcingCondition } from "./sourcing-condition.js"
|
|
3
|
+
import type { SourcingResult } from "./event-store.js"
|
|
4
|
+
import type { AppendCondition } from "./append-condition.js"
|
|
5
|
+
import type { ConsistencyMarker } from "./consistency-marker.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A transactional handle for an append operation.
|
|
9
|
+
*
|
|
10
|
+
* The two-phase pattern allows the processing lifecycle to control when
|
|
11
|
+
* events become visible:
|
|
12
|
+
*
|
|
13
|
+
* 1. {@link appendEvents} stages events and returns an AppendTransaction
|
|
14
|
+
* 2. {@link commit} makes events visible to consumers
|
|
15
|
+
* 3. {@link afterCommit} returns the consistency marker
|
|
16
|
+
* 4. {@link rollback} discards staged events on failure
|
|
17
|
+
*/
|
|
18
|
+
export interface AppendTransaction {
|
|
19
|
+
/** Make appended events visible to consumers. */
|
|
20
|
+
commit(): Promise<void>
|
|
21
|
+
/** Get the consistency marker after a successful commit. */
|
|
22
|
+
afterCommit(): Promise<ConsistencyMarker>
|
|
23
|
+
/** Discard staged events. */
|
|
24
|
+
rollback(): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Infrastructure-level abstraction for event persistence.
|
|
29
|
+
*
|
|
30
|
+
* This is the raw storage mechanism — append, source, and stream events.
|
|
31
|
+
* Database extensions (drizzle, knex, prisma, etc.) implement this interface
|
|
32
|
+
* to provide persistent event storage.
|
|
33
|
+
*
|
|
34
|
+
* Not intended for direct use by application code. The {@link EventStore}
|
|
35
|
+
* composes an EventStorageEngine with event distribution (EventSink) and
|
|
36
|
+
* tag resolution (TagResolver).
|
|
37
|
+
*/
|
|
38
|
+
export interface EventStorageEngine extends StreamableEventSource {
|
|
39
|
+
/**
|
|
40
|
+
* Source events matching the given condition (criteria-based, for state sourcing).
|
|
41
|
+
* Returns the matching events and a consistency marker.
|
|
42
|
+
*/
|
|
43
|
+
source(condition: SourcingCondition): Promise<SourcingResult>
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Append events to the store.
|
|
47
|
+
* If an append condition is provided, the engine verifies that no conflicting
|
|
48
|
+
* events were written since the marker before appending.
|
|
49
|
+
*
|
|
50
|
+
* Returns an {@link AppendTransaction} for two-phase commit control.
|
|
51
|
+
* For simple cases, use the convenience form that auto-commits:
|
|
52
|
+
* ```typescript
|
|
53
|
+
* const marker = await store.append(events, condition)
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
appendEvents(
|
|
57
|
+
events: ReadonlyArray<EventMessage>,
|
|
58
|
+
condition?: AppendCondition,
|
|
59
|
+
): Promise<AppendTransaction>
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convenience method that appends events and auto-commits in one step.
|
|
63
|
+
* Equivalent to calling appendEvents() followed by commit() and afterCommit().
|
|
64
|
+
*/
|
|
65
|
+
append(
|
|
66
|
+
events: ReadonlyArray<EventMessage>,
|
|
67
|
+
condition?: AppendCondition,
|
|
68
|
+
): Promise<ConsistencyMarker>
|
|
69
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { EventMessage } from "@kronos-ts/messaging"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A transaction scope for event store operations within a UnitOfWork.
|
|
5
|
+
*
|
|
6
|
+
* Events are buffered during the transaction and only persisted when
|
|
7
|
+
* the UnitOfWork commits. The `onAppend` hook enables state cache
|
|
8
|
+
* updates as events are buffered (before persistence).
|
|
9
|
+
*/
|
|
10
|
+
export interface EventStoreTransaction {
|
|
11
|
+
/**
|
|
12
|
+
* Buffer an event for append. The event is not yet persisted —
|
|
13
|
+
* it will be written to the store at PREPARE_COMMIT.
|
|
14
|
+
*/
|
|
15
|
+
appendEvent(event: EventMessage): void
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register a callback invoked each time an event is buffered via `appendEvent`.
|
|
19
|
+
* Used by the state cache to apply events to cached state immediately,
|
|
20
|
+
* keeping the cache consistent within the same UnitOfWork.
|
|
21
|
+
*/
|
|
22
|
+
onAppend(callback: (event: EventMessage) => void): void
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get all buffered events (not yet committed).
|
|
26
|
+
*/
|
|
27
|
+
readonly bufferedEvents: ReadonlyArray<EventMessage>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates an EventStoreTransaction that buffers events and notifies
|
|
32
|
+
* registered callbacks.
|
|
33
|
+
*/
|
|
34
|
+
export function createEventStoreTransaction(): EventStoreTransaction {
|
|
35
|
+
const events: EventMessage[] = []
|
|
36
|
+
const appendCallbacks: Array<(event: EventMessage) => void> = []
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
appendEvent(event: EventMessage): void {
|
|
40
|
+
events.push(event)
|
|
41
|
+
for (const callback of appendCallbacks) {
|
|
42
|
+
try {
|
|
43
|
+
callback(event)
|
|
44
|
+
} catch (e) {
|
|
45
|
+
console.warn("EventStoreTransaction: onAppend callback threw an exception:", e)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
onAppend(callback: (event: EventMessage) => void): void {
|
|
51
|
+
appendCallbacks.push(callback)
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
get bufferedEvents(): ReadonlyArray<EventMessage> {
|
|
55
|
+
return events
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { EventMessage, EventBus } from "@kronos-ts/messaging"
|
|
2
|
+
import type { EventStorageEngine } from "./event-storage-engine.js"
|
|
3
|
+
import type { ConsistencyMarker } from "./consistency-marker.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result of sourcing events — the events plus a consistency marker
|
|
7
|
+
* representing the position up to which events were read.
|
|
8
|
+
*/
|
|
9
|
+
export interface SourcingResult {
|
|
10
|
+
readonly events: ReadonlyArray<EventMessage>
|
|
11
|
+
readonly marker: ConsistencyMarker
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The event store — dual-role component that combines event storage
|
|
16
|
+
* with event distribution.
|
|
17
|
+
*
|
|
18
|
+
* Extends:
|
|
19
|
+
* - `EventStorageEngine` — raw storage (source, append, stream)
|
|
20
|
+
* - `EventBus` — event publication + push-based subscription
|
|
21
|
+
*
|
|
22
|
+
* In an event sourcing context, the EventStore persists events durably while
|
|
23
|
+
* simultaneously distributing them to subscribed event handlers, eliminating
|
|
24
|
+
* the need for a separate EventBus component.
|
|
25
|
+
*/
|
|
26
|
+
export interface EventStore extends EventStorageEngine, EventBus {}
|