@kronos-ts/messaging 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/command-bus.d.ts +30 -0
- package/dist/command-bus.d.ts.map +1 -0
- package/dist/command-bus.js +2 -0
- package/dist/command-bus.js.map +1 -0
- package/dist/command-handler.d.ts +58 -0
- package/dist/command-handler.d.ts.map +1 -0
- package/dist/command-handler.js +12 -0
- package/dist/command-handler.js.map +1 -0
- package/dist/command-handling-module.d.ts +53 -0
- package/dist/command-handling-module.d.ts.map +1 -0
- package/dist/command-handling-module.js +130 -0
- package/dist/command-handling-module.js.map +1 -0
- package/dist/correlation-data.d.ts +79 -0
- package/dist/correlation-data.d.ts.map +1 -0
- package/dist/correlation-data.js +133 -0
- package/dist/correlation-data.js.map +1 -0
- package/dist/dead-letter-queue.d.ts +134 -0
- package/dist/dead-letter-queue.d.ts.map +1 -0
- package/dist/dead-letter-queue.js +176 -0
- package/dist/dead-letter-queue.js.map +1 -0
- package/dist/dead-lettering-handler.d.ts +42 -0
- package/dist/dead-lettering-handler.d.ts.map +1 -0
- package/dist/dead-lettering-handler.js +67 -0
- package/dist/dead-lettering-handler.js.map +1 -0
- package/dist/descriptor.d.ts +135 -0
- package/dist/descriptor.d.ts.map +1 -0
- package/dist/descriptor.js +36 -0
- package/dist/descriptor.js.map +1 -0
- package/dist/emit-update.d.ts +22 -0
- package/dist/emit-update.d.ts.map +1 -0
- package/dist/emit-update.js +23 -0
- package/dist/emit-update.js.map +1 -0
- package/dist/event-bus.d.ts +29 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +22 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/event-criteria.d.ts +87 -0
- package/dist/event-criteria.d.ts.map +1 -0
- package/dist/event-criteria.js +90 -0
- package/dist/event-criteria.js.map +1 -0
- package/dist/event-gateway.d.ts +19 -0
- package/dist/event-gateway.d.ts.map +1 -0
- package/dist/event-gateway.js +22 -0
- package/dist/event-gateway.js.map +1 -0
- package/dist/event-handler.d.ts +30 -0
- package/dist/event-handler.d.ts.map +1 -0
- package/dist/event-handler.js +18 -0
- package/dist/event-handler.js.map +1 -0
- package/dist/event-processor-builder.d.ts +148 -0
- package/dist/event-processor-builder.d.ts.map +1 -0
- package/dist/event-processor-builder.js +175 -0
- package/dist/event-processor-builder.js.map +1 -0
- package/dist/event-processor.d.ts +10 -0
- package/dist/event-processor.d.ts.map +1 -0
- package/dist/event-processor.js +2 -0
- package/dist/event-processor.js.map +1 -0
- package/dist/event-sink.d.ts +23 -0
- package/dist/event-sink.d.ts.map +1 -0
- package/dist/event-sink.js +2 -0
- package/dist/event-sink.js.map +1 -0
- package/dist/event-source.d.ts +98 -0
- package/dist/event-source.d.ts.map +1 -0
- package/dist/event-source.js +191 -0
- package/dist/event-source.js.map +1 -0
- package/dist/gateway.d.ts +68 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +62 -0
- package/dist/gateway.js.map +1 -0
- package/dist/handler-enhancer.d.ts +53 -0
- package/dist/handler-enhancer.d.ts.map +1 -0
- package/dist/handler-enhancer.js +17 -0
- package/dist/handler-enhancer.js.map +1 -0
- package/dist/handler.d.ts +51 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +26 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +103 -0
- package/dist/index.js.map +1 -0
- package/dist/intercepting-command-bus.d.ts +17 -0
- package/dist/intercepting-command-bus.d.ts.map +1 -0
- package/dist/intercepting-command-bus.js +54 -0
- package/dist/intercepting-command-bus.js.map +1 -0
- package/dist/intercepting-event-bus.d.ts +8 -0
- package/dist/intercepting-event-bus.d.ts.map +1 -0
- package/dist/intercepting-event-bus.js +22 -0
- package/dist/intercepting-event-bus.js.map +1 -0
- package/dist/intercepting-query-bus.d.ts +17 -0
- package/dist/intercepting-query-bus.d.ts.map +1 -0
- package/dist/intercepting-query-bus.js +68 -0
- package/dist/intercepting-query-bus.js.map +1 -0
- package/dist/interceptor.d.ts +46 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/interceptor.js +2 -0
- package/dist/interceptor.js.map +1 -0
- package/dist/message-monitor-registry.d.ts +28 -0
- package/dist/message-monitor-registry.d.ts.map +1 -0
- package/dist/message-monitor-registry.js +37 -0
- package/dist/message-monitor-registry.js.map +1 -0
- package/dist/message-monitor.d.ts +36 -0
- package/dist/message-monitor.d.ts.map +1 -0
- package/dist/message-monitor.js +39 -0
- package/dist/message-monitor.js.map +1 -0
- package/dist/message.d.ts +42 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/message.js +2 -0
- package/dist/message.js.map +1 -0
- package/dist/processing-state.d.ts +115 -0
- package/dist/processing-state.d.ts.map +1 -0
- package/dist/processing-state.js +205 -0
- package/dist/processing-state.js.map +1 -0
- package/dist/processor-configuration.d.ts +51 -0
- package/dist/processor-configuration.d.ts.map +1 -0
- package/dist/processor-configuration.js +2 -0
- package/dist/processor-configuration.js.map +1 -0
- package/dist/query-bus.d.ts +51 -0
- package/dist/query-bus.d.ts.map +1 -0
- package/dist/query-bus.js +2 -0
- package/dist/query-bus.js.map +1 -0
- package/dist/query-handler.d.ts +35 -0
- package/dist/query-handler.d.ts.map +1 -0
- package/dist/query-handler.js +19 -0
- package/dist/query-handler.js.map +1 -0
- package/dist/query-handling-module.d.ts +24 -0
- package/dist/query-handling-module.d.ts.map +1 -0
- package/dist/query-handling-module.js +32 -0
- package/dist/query-handling-module.js.map +1 -0
- package/dist/replay-token.d.ts +31 -0
- package/dist/replay-token.d.ts.map +1 -0
- package/dist/replay-token.js +37 -0
- package/dist/replay-token.js.map +1 -0
- package/dist/retrying-command-bus.d.ts +32 -0
- package/dist/retrying-command-bus.d.ts.map +1 -0
- package/dist/retrying-command-bus.js +58 -0
- package/dist/retrying-command-bus.js.map +1 -0
- package/dist/routing-strategy.d.ts +30 -0
- package/dist/routing-strategy.d.ts.map +1 -0
- package/dist/routing-strategy.js +37 -0
- package/dist/routing-strategy.js.map +1 -0
- package/dist/segment.d.ts +72 -0
- package/dist/segment.d.ts.map +1 -0
- package/dist/segment.js +103 -0
- package/dist/segment.js.map +1 -0
- package/dist/send.d.ts +28 -0
- package/dist/send.d.ts.map +1 -0
- package/dist/send.js +36 -0
- package/dist/send.js.map +1 -0
- package/dist/serializer.d.ts +40 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +90 -0
- package/dist/serializer.js.map +1 -0
- package/dist/simple-command-bus.d.ts +23 -0
- package/dist/simple-command-bus.d.ts.map +1 -0
- package/dist/simple-command-bus.js +49 -0
- package/dist/simple-command-bus.js.map +1 -0
- package/dist/simple-query-bus.d.ts +16 -0
- package/dist/simple-query-bus.d.ts.map +1 -0
- package/dist/simple-query-bus.js +122 -0
- package/dist/simple-query-bus.js.map +1 -0
- package/dist/span-factory.d.ts +58 -0
- package/dist/span-factory.d.ts.map +1 -0
- package/dist/span-factory.js +19 -0
- package/dist/span-factory.js.map +1 -0
- package/dist/streaming-event-processor.d.ts +65 -0
- package/dist/streaming-event-processor.d.ts.map +1 -0
- package/dist/streaming-event-processor.js +239 -0
- package/dist/streaming-event-processor.js.map +1 -0
- package/dist/subscribing-event-processor.d.ts +57 -0
- package/dist/subscribing-event-processor.d.ts.map +1 -0
- package/dist/subscribing-event-processor.js +100 -0
- package/dist/subscribing-event-processor.js.map +1 -0
- package/dist/subscription-query.d.ts +63 -0
- package/dist/subscription-query.d.ts.map +1 -0
- package/dist/subscription-query.js +119 -0
- package/dist/subscription-query.js.map +1 -0
- package/dist/token-store.d.ts +83 -0
- package/dist/token-store.d.ts.map +1 -0
- package/dist/token-store.js +112 -0
- package/dist/token-store.js.map +1 -0
- package/dist/tracing-command-bus.d.ts +16 -0
- package/dist/tracing-command-bus.d.ts.map +1 -0
- package/dist/tracing-command-bus.js +44 -0
- package/dist/tracing-command-bus.js.map +1 -0
- package/dist/tracing-handler-enhancer.d.ts +11 -0
- package/dist/tracing-handler-enhancer.d.ts.map +1 -0
- package/dist/tracing-handler-enhancer.js +27 -0
- package/dist/tracing-handler-enhancer.js.map +1 -0
- package/dist/tracking-event-processor.d.ts +72 -0
- package/dist/tracking-event-processor.d.ts.map +1 -0
- package/dist/tracking-event-processor.js +223 -0
- package/dist/tracking-event-processor.js.map +1 -0
- package/dist/tracking-token.d.ts +120 -0
- package/dist/tracking-token.d.ts.map +1 -0
- package/dist/tracking-token.js +132 -0
- package/dist/tracking-token.js.map +1 -0
- package/dist/transaction.d.ts +60 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +74 -0
- package/dist/transaction.js.map +1 -0
- package/dist/unit-of-work.d.ts +41 -0
- package/dist/unit-of-work.d.ts.map +1 -0
- package/dist/unit-of-work.js +96 -0
- package/dist/unit-of-work.js.map +1 -0
- package/dist/upcaster.d.ts +91 -0
- package/dist/upcaster.d.ts.map +1 -0
- package/dist/upcaster.js +114 -0
- package/dist/upcaster.js.map +1 -0
- package/dist/with-namespace.d.ts +59 -0
- package/dist/with-namespace.d.ts.map +1 -0
- package/dist/with-namespace.js +42 -0
- package/dist/with-namespace.js.map +1 -0
- package/package.json +65 -0
- package/src/command-bus.ts +34 -0
- package/src/command-handler.ts +116 -0
- package/src/command-handling-module.ts +183 -0
- package/src/correlation-data.ts +169 -0
- package/src/dead-letter-queue.ts +330 -0
- package/src/dead-lettering-handler.ts +109 -0
- package/src/descriptor.ts +176 -0
- package/src/emit-update.ts +35 -0
- package/src/event-bus.ts +45 -0
- package/src/event-criteria.ts +141 -0
- package/src/event-gateway.ts +42 -0
- package/src/event-handler.ts +44 -0
- package/src/event-processor-builder.ts +246 -0
- package/src/event-processor.ts +9 -0
- package/src/event-sink.ts +23 -0
- package/src/event-source.ts +301 -0
- package/src/gateway.ts +144 -0
- package/src/handler-enhancer.ts +70 -0
- package/src/handler.ts +133 -0
- package/src/index.ts +356 -0
- package/src/intercepting-command-bus.ts +73 -0
- package/src/intercepting-event-bus.ts +29 -0
- package/src/intercepting-query-bus.ts +104 -0
- package/src/interceptor.ts +48 -0
- package/src/message-monitor-registry.ts +64 -0
- package/src/message-monitor.ts +68 -0
- package/src/message.ts +41 -0
- package/src/processing-state.ts +258 -0
- package/src/processor-configuration.ts +59 -0
- package/src/query-bus.ts +69 -0
- package/src/query-handler.ts +49 -0
- package/src/query-handling-module.ts +44 -0
- package/src/replay-token.ts +53 -0
- package/src/retrying-command-bus.ts +80 -0
- package/src/routing-strategy.ts +59 -0
- package/src/segment.ts +136 -0
- package/src/send.ts +44 -0
- package/src/serializer.ts +122 -0
- package/src/simple-command-bus.ts +59 -0
- package/src/simple-query-bus.ts +158 -0
- package/src/span-factory.ts +81 -0
- package/src/streaming-event-processor.ts +351 -0
- package/src/subscribing-event-processor.ts +169 -0
- package/src/subscription-query.ts +173 -0
- package/src/token-store.ts +211 -0
- package/src/tracing-command-bus.ts +52 -0
- package/src/tracing-handler-enhancer.ts +34 -0
- package/src/tracking-event-processor.ts +336 -0
- package/src/tracking-token.ts +231 -0
- package/src/transaction.ts +98 -0
- package/src/unit-of-work.ts +138 -0
- package/src/upcaster.ts +174 -0
- package/src/with-namespace.ts +75 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { CommandBus } from "./command-bus.js"
|
|
2
|
+
import type { CommandMessage } from "./message.js"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Determines whether a failed command dispatch should be retried.
|
|
6
|
+
*/
|
|
7
|
+
export interface RetryPolicy {
|
|
8
|
+
/**
|
|
9
|
+
* Returns the delay in ms before the next retry, or undefined to stop retrying.
|
|
10
|
+
* @param error The error from the previous attempt
|
|
11
|
+
* @param attempt The attempt number (0-based, so 0 = first failure)
|
|
12
|
+
*/
|
|
13
|
+
shouldRetry(error: unknown, attempt: number): number | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Retries on transient errors (like AppendConditionError) with exponential backoff.
|
|
18
|
+
* Non-transient errors are rethrown immediately.
|
|
19
|
+
*/
|
|
20
|
+
export function exponentialBackoffRetryPolicy(options?: {
|
|
21
|
+
maxRetries?: number
|
|
22
|
+
initialDelayMs?: number
|
|
23
|
+
isTransient?: (error: unknown) => boolean
|
|
24
|
+
}): RetryPolicy {
|
|
25
|
+
const maxRetries = options?.maxRetries ?? 5
|
|
26
|
+
const initialDelayMs = options?.initialDelayMs ?? 10
|
|
27
|
+
const isTransient = options?.isTransient ?? defaultIsTransient
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
shouldRetry(error: unknown, attempt: number): number | undefined {
|
|
31
|
+
if (attempt >= maxRetries) return undefined
|
|
32
|
+
if (!isTransient(error)) return undefined
|
|
33
|
+
return initialDelayMs * Math.pow(2, attempt)
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function defaultIsTransient(error: unknown): boolean {
|
|
39
|
+
if (error instanceof Error) {
|
|
40
|
+
return error.name === "AppendConditionError"
|
|
41
|
+
}
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A command bus decorator that retries failed dispatches based on a retry policy.
|
|
47
|
+
*
|
|
48
|
+
* When a command fails with a transient error (e.g., AppendConditionError from
|
|
49
|
+
* an optimistic concurrency conflict), the entire dispatch is retried. This means
|
|
50
|
+
* the handler re-sources state and re-makes its decision based on fresh data.
|
|
51
|
+
*
|
|
52
|
+
* Non-transient errors propagate immediately to the caller.
|
|
53
|
+
*/
|
|
54
|
+
export function createRetryingCommandBus(
|
|
55
|
+
delegate: CommandBus,
|
|
56
|
+
policy: RetryPolicy,
|
|
57
|
+
): CommandBus {
|
|
58
|
+
return {
|
|
59
|
+
async dispatch(message: CommandMessage): Promise<unknown> {
|
|
60
|
+
let attempt = 0
|
|
61
|
+
while (true) {
|
|
62
|
+
try {
|
|
63
|
+
return await delegate.dispatch(message)
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const delay = policy.shouldRetry(error, attempt)
|
|
66
|
+
if (delay === undefined) throw error
|
|
67
|
+
|
|
68
|
+
attempt++
|
|
69
|
+
if (delay > 0) {
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
subscribe(commandName: string, handler: (message: CommandMessage) => Promise<unknown>) {
|
|
77
|
+
delegate.subscribe(commandName, handler)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CommandMessage } from "./message.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Determines the routing key for a command message.
|
|
5
|
+
*
|
|
6
|
+
* Used by the distributed command bus to route commands to the correct
|
|
7
|
+
* handler instance via consistent hashing. Commands with the same routing
|
|
8
|
+
* key are routed to the same handler.
|
|
9
|
+
*/
|
|
10
|
+
export interface RoutingStrategy {
|
|
11
|
+
/**
|
|
12
|
+
* Get the routing key for the given command message.
|
|
13
|
+
* Returns a string that identifies the target for this command
|
|
14
|
+
* (typically an aggregate identifier).
|
|
15
|
+
*/
|
|
16
|
+
getRoutingKey(message: CommandMessage): string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extracts the routing key from a command message's metadata.
|
|
21
|
+
*
|
|
22
|
+
* @param metadataKey The metadata key to extract the routing key from.
|
|
23
|
+
*/
|
|
24
|
+
export function metadataRoutingStrategy(metadataKey: string): RoutingStrategy {
|
|
25
|
+
return {
|
|
26
|
+
getRoutingKey(message: CommandMessage): string {
|
|
27
|
+
const value = message.metadata[metadataKey]
|
|
28
|
+
if (value == null) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`No routing key found in metadata key "${metadataKey}" ` +
|
|
31
|
+
`for command "${String(message.name)}"`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
return String(value)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extracts the routing key from a field of the command payload.
|
|
41
|
+
* The field name is specified explicitly on the descriptor/configuration.
|
|
42
|
+
*
|
|
43
|
+
* @param field The payload field to extract the routing key from.
|
|
44
|
+
*/
|
|
45
|
+
export function payloadFieldRoutingStrategy(field: string): RoutingStrategy {
|
|
46
|
+
return {
|
|
47
|
+
getRoutingKey(message: CommandMessage): string {
|
|
48
|
+
const payload = message.payload as Record<string, unknown>
|
|
49
|
+
const value = payload?.[field]
|
|
50
|
+
if (value == null) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`No routing key found in payload field "${field}" ` +
|
|
53
|
+
`for command "${String(message.name)}"`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
return String(value)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/segment.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Represents a fraction of the event stream assigned to a processor instance.
|
|
3
|
+
*
|
|
4
|
+
* Segments use bitmask-based routing to deterministically assign events
|
|
5
|
+
* to processors. An event matches a segment when `(hash(event) & mask) === segmentId`.
|
|
6
|
+
*
|
|
7
|
+
* Segments can be split (doubling parallelism) and merged (halving parallelism).
|
|
8
|
+
* This is the foundation for horizontal scaling — multiple instances of the
|
|
9
|
+
* same processor each claim different segments.
|
|
10
|
+
*
|
|
11
|
+
* The ROOT_SEGMENT (segmentId=0, mask=0) matches ALL events and is the
|
|
12
|
+
* starting point before any splitting.
|
|
13
|
+
*/
|
|
14
|
+
export interface Segment {
|
|
15
|
+
/** The unique identifier of this segment. */
|
|
16
|
+
readonly segmentId: number
|
|
17
|
+
/**
|
|
18
|
+
* The bitmask used to match events to this segment.
|
|
19
|
+
* An event matches when `(hash & mask) === segmentId`.
|
|
20
|
+
*/
|
|
21
|
+
readonly mask: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The root segment — matches all events. Starting point before splitting. */
|
|
25
|
+
export const ROOT_SEGMENT: Segment = { segmentId: 0, mask: 0 }
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a segment with the given id and mask.
|
|
29
|
+
*/
|
|
30
|
+
export function segment(segmentId: number, mask: number): Segment {
|
|
31
|
+
return { segmentId, mask }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if a hash value matches this segment.
|
|
36
|
+
* Used to route events to the correct processor instance.
|
|
37
|
+
*
|
|
38
|
+
* @param seg The segment to check against
|
|
39
|
+
* @param hash The hash of the event's sequence identifier (e.g., aggregate ID hash)
|
|
40
|
+
*/
|
|
41
|
+
export function segmentMatches(seg: Segment, hash: number): boolean {
|
|
42
|
+
return (hash & seg.mask) === seg.segmentId
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Split a segment into two child segments.
|
|
47
|
+
* Doubles the processing parallelism for this segment's portion of the stream.
|
|
48
|
+
*
|
|
49
|
+
* Returns a tuple of [segment keeping the original ID, new sibling segment].
|
|
50
|
+
*/
|
|
51
|
+
export function splitSegment(seg: Segment): [Segment, Segment] {
|
|
52
|
+
const newMask = (seg.mask << 1) | 1
|
|
53
|
+
const newSegmentId = seg.segmentId | (seg.mask + 1)
|
|
54
|
+
|
|
55
|
+
return [
|
|
56
|
+
{ segmentId: seg.segmentId, mask: newMask },
|
|
57
|
+
{ segmentId: newSegmentId, mask: newMask },
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if two segments can be merged (they are siblings from the same split).
|
|
63
|
+
*/
|
|
64
|
+
export function isMergeable(a: Segment, b: Segment): boolean {
|
|
65
|
+
if (a.mask !== b.mask) return false
|
|
66
|
+
if (a.mask === 0) return false
|
|
67
|
+
// Siblings differ only in the highest bit of the mask
|
|
68
|
+
return (a.segmentId ^ b.segmentId) === (a.mask >>> 0) - (a.mask >>> 1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge two sibling segments back into their parent.
|
|
73
|
+
* Halves the processing parallelism.
|
|
74
|
+
*
|
|
75
|
+
* @throws Error if segments are not mergeable
|
|
76
|
+
*/
|
|
77
|
+
export function mergeSegments(a: Segment, b: Segment): Segment {
|
|
78
|
+
if (!isMergeable(a, b)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Segments ${a.segmentId}/${a.mask} and ${b.segmentId}/${b.mask} are not mergeable`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
segmentId: Math.min(a.segmentId, b.segmentId),
|
|
86
|
+
mask: a.mask >>> 1,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Compute the total number of segments at the current split level.
|
|
92
|
+
* For a segment with mask M, the total count is M + 1.
|
|
93
|
+
*/
|
|
94
|
+
export function segmentCount(seg: Segment): number {
|
|
95
|
+
return seg.mask + 1
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compute a hash from a string value (for event routing).
|
|
100
|
+
* Uses a simple but well-distributed hash function.
|
|
101
|
+
*/
|
|
102
|
+
export function hashOf(value: string): number {
|
|
103
|
+
let hash = 0
|
|
104
|
+
for (let i = 0; i < value.length; i++) {
|
|
105
|
+
const char = value.charCodeAt(i)
|
|
106
|
+
hash = ((hash << 5) - hash + char) | 0
|
|
107
|
+
}
|
|
108
|
+
return hash >>> 0 // Ensure unsigned
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Create N balanced segments by splitting the root segment.
|
|
113
|
+
*
|
|
114
|
+
* @param count Number of segments (must be a power of 2)
|
|
115
|
+
* @returns Array of segments that together cover the entire event stream
|
|
116
|
+
*/
|
|
117
|
+
export function createSegments(count: number): Segment[] {
|
|
118
|
+
if (count <= 0) throw new Error("Segment count must be positive")
|
|
119
|
+
if (count === 1) return [ROOT_SEGMENT]
|
|
120
|
+
|
|
121
|
+
// Find the nearest power of 2
|
|
122
|
+
const power = Math.ceil(Math.log2(count))
|
|
123
|
+
const actualCount = Math.pow(2, power)
|
|
124
|
+
|
|
125
|
+
let segments: Segment[] = [ROOT_SEGMENT]
|
|
126
|
+
while (segments.length < actualCount) {
|
|
127
|
+
const next: Segment[] = []
|
|
128
|
+
for (const seg of segments) {
|
|
129
|
+
const [a, b] = splitSegment(seg)
|
|
130
|
+
next.push(a, b)
|
|
131
|
+
}
|
|
132
|
+
segments = next
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return segments
|
|
136
|
+
}
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { resourceKey, generateIdentifier, type ResourceKey } from "@kronos-ts/common"
|
|
2
|
+
import { requireInvocationPhase } from "./processing-state.js"
|
|
3
|
+
import type { CommandBus } from "./command-bus.js"
|
|
4
|
+
import type { CommandDescriptor } from "./descriptor.js"
|
|
5
|
+
import type { z } from "zod"
|
|
6
|
+
|
|
7
|
+
type CommandDispatchFunction = <P extends z.ZodType, R extends z.ZodType | undefined = undefined>(
|
|
8
|
+
descriptor: CommandDescriptor<P, R>,
|
|
9
|
+
payload: z.infer<P>,
|
|
10
|
+
) => Promise<unknown>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resource key for the command bus component.
|
|
14
|
+
* Written by handling modules + processors at handler-invocation entry (D-44).
|
|
15
|
+
*/
|
|
16
|
+
export const COMMAND_BUS_KEY: ResourceKey<CommandBus> = resourceKey("commandBus")
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Send a command from inside a handler.
|
|
20
|
+
*
|
|
21
|
+
* AF5-aligned semantics: every command is handled in its own fresh
|
|
22
|
+
* UnitOfWork (`commandBus.dispatch` always starts a new one — see
|
|
23
|
+
* `createSimpleCommandBus`). The command handler is therefore its own
|
|
24
|
+
* atomic boundary: it loads state, decides, appends events, and commits
|
|
25
|
+
* once — independent of the caller's UnitOfWork.
|
|
26
|
+
*
|
|
27
|
+
* The caller's `metadata` IS carried onto the outgoing command, so
|
|
28
|
+
* correlation/causation lineage propagates the AF5 way — through message
|
|
29
|
+
* metadata, applied by the correlation-data dispatch interceptor — across
|
|
30
|
+
* any transport, local or distributed. No processing-context object is
|
|
31
|
+
* threaded through the command API or over the wire.
|
|
32
|
+
*/
|
|
33
|
+
export const send: CommandDispatchFunction = async (descriptor, payload) => {
|
|
34
|
+
const state = requireInvocationPhase() // D-43 mutator guard
|
|
35
|
+
const bus = state.resources.get(COMMAND_BUS_KEY.symbol) as CommandBus | undefined
|
|
36
|
+
if (!bus) throw new Error("No command bus configured")
|
|
37
|
+
return bus.dispatch({
|
|
38
|
+
identifier: generateIdentifier(),
|
|
39
|
+
name: descriptor.name,
|
|
40
|
+
payload,
|
|
41
|
+
metadata: state.metadata,
|
|
42
|
+
timestamp: Date.now(),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Serializer, SerializedObject } from "@kronos-ts/common"
|
|
2
|
+
import type { z } from "zod"
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder()
|
|
5
|
+
const decoder = new TextDecoder()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* JSON serializer — the default serializer for the TypeScript framework.
|
|
9
|
+
*
|
|
10
|
+
* Serializes values as JSON-encoded Uint8Array. Handles `undefined`,
|
|
11
|
+
* `null`, and all JSON-compatible values.
|
|
12
|
+
*/
|
|
13
|
+
export function jsonSerializer(): Serializer {
|
|
14
|
+
return {
|
|
15
|
+
serialize(value: unknown, type: string, revision: string = ""): SerializedObject {
|
|
16
|
+
return {
|
|
17
|
+
type,
|
|
18
|
+
revision,
|
|
19
|
+
data: encoder.encode(JSON.stringify(value)),
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
deserialize<T>(data: SerializedObject): T {
|
|
24
|
+
if (data.data.length === 0) return undefined as T
|
|
25
|
+
return JSON.parse(decoder.decode(data.data)) as T
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
canConvert(): boolean {
|
|
29
|
+
return true
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Schema registries — per message type
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A registry of Zod schemas indexed by type name + revision.
|
|
40
|
+
* Used by the validating serializer decorator to validate
|
|
41
|
+
* deserialized payloads against their expected schema.
|
|
42
|
+
*/
|
|
43
|
+
export interface SchemaRegistry {
|
|
44
|
+
register(typeName: string, revision: string, schema: z.ZodType): void
|
|
45
|
+
get(typeName: string, revision: string): z.ZodType | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Schema registry for event payloads. */
|
|
49
|
+
export function createEventSchemaRegistry(): SchemaRegistry {
|
|
50
|
+
return createSchemaRegistry()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Schema registry for command payloads. */
|
|
54
|
+
export function createCommandSchemaRegistry(): SchemaRegistry {
|
|
55
|
+
return createSchemaRegistry()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Schema registry for query payloads. */
|
|
59
|
+
export function createQuerySchemaRegistry(): SchemaRegistry {
|
|
60
|
+
return createSchemaRegistry()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createSchemaRegistry(): SchemaRegistry {
|
|
64
|
+
const schemas = new Map<string, z.ZodType>()
|
|
65
|
+
|
|
66
|
+
function key(typeName: string, revision: string): string {
|
|
67
|
+
return `${typeName}@${revision}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
register(typeName, revision, schema) {
|
|
72
|
+
schemas.set(key(typeName, revision), schema)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
get(typeName, revision) {
|
|
76
|
+
// Try exact match first, then fallback to no revision
|
|
77
|
+
return schemas.get(key(typeName, revision)) ?? schemas.get(key(typeName, ""))
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Zod-validating serializer decorator
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wraps a delegate serializer with Zod validation on deserialization.
|
|
88
|
+
*
|
|
89
|
+
* When deserializing, looks up the schema in the registry by type name
|
|
90
|
+
* and revision. If found, validates the deserialized value against it.
|
|
91
|
+
* If not found, passes through without validation.
|
|
92
|
+
*
|
|
93
|
+
* ```typescript
|
|
94
|
+
* const serializer = zodValidatingSerializer(
|
|
95
|
+
* jsonSerializer(),
|
|
96
|
+
* mySchemaRegistry,
|
|
97
|
+
* )
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function zodValidatingSerializer(
|
|
101
|
+
delegate: Serializer,
|
|
102
|
+
schemaRegistry: SchemaRegistry,
|
|
103
|
+
): Serializer {
|
|
104
|
+
return {
|
|
105
|
+
serialize(value, type, revision) {
|
|
106
|
+
return delegate.serialize(value, type, revision)
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
deserialize<T>(data: SerializedObject): T {
|
|
110
|
+
const raw = delegate.deserialize<unknown>(data)
|
|
111
|
+
const schema = schemaRegistry.get(data.type, data.revision)
|
|
112
|
+
if (schema) {
|
|
113
|
+
return schema.parse(raw) as T
|
|
114
|
+
}
|
|
115
|
+
return raw as T
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
canConvert(type) {
|
|
119
|
+
return delegate.canConvert(type)
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CommandBus } from "./command-bus.js"
|
|
2
|
+
import type { CommandMessage } from "./message.js"
|
|
3
|
+
import { runInNewUoW } from "./unit-of-work.js"
|
|
4
|
+
import { qualifiedNameToString } from "@kronos-ts/common"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Simple in-process command bus.
|
|
8
|
+
*
|
|
9
|
+
* Maintains a local handler map and dispatches commands directly,
|
|
10
|
+
* wrapping each dispatch in a fresh UnitOfWork via `runInNewUoW`.
|
|
11
|
+
*
|
|
12
|
+
* AF5 parity: like `SimpleCommandBus`, every command — primary OR nested
|
|
13
|
+
* (dispatched from inside another handler via `send()`) — is handled in
|
|
14
|
+
* its own independent UnitOfWork with its own commit boundary. A command
|
|
15
|
+
* handler is the atomic unit; commands compose by independent commit, not
|
|
16
|
+
* by sharing a transaction. DCB read-set / append-condition merging
|
|
17
|
+
* happens only WITHIN a single handler's UnitOfWork.
|
|
18
|
+
*
|
|
19
|
+
* Transactional wiring composes at the runner level via
|
|
20
|
+
* `transactionalUnitOfWorkFactory(runInNewUoW, txManager)` and is consumed
|
|
21
|
+
* by extensions / processors directly, not by the bus.
|
|
22
|
+
*
|
|
23
|
+
* Interceptor support is provided by wrapping with
|
|
24
|
+
* {@link createInterceptingCommandBus}.
|
|
25
|
+
*/
|
|
26
|
+
export function createSimpleCommandBus(): CommandBus {
|
|
27
|
+
const handlers = new Map<string, (message: CommandMessage) => Promise<unknown>>()
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async dispatch(message: CommandMessage): Promise<unknown> {
|
|
31
|
+
const key = qualifiedNameToString(message.name)
|
|
32
|
+
const handler = handlers.get(key)
|
|
33
|
+
if (!handler) {
|
|
34
|
+
throw new Error(`No handler registered for command "${key}"`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// AF5 parity: every command gets its own fresh UnitOfWork, even when
|
|
38
|
+
// dispatched from inside another handler. Dispatch interceptors have
|
|
39
|
+
// already run in the caller's context (the intercepting bus wraps
|
|
40
|
+
// this one), so correlation data is carried on `message.metadata`
|
|
41
|
+
// before we cross into the new UoW.
|
|
42
|
+
return runInNewUoW(message.metadata, () => handler(message))
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
subscribe(
|
|
46
|
+
commandName: string,
|
|
47
|
+
handler: (message: CommandMessage) => Promise<unknown>,
|
|
48
|
+
) {
|
|
49
|
+
const existing = handlers.get(commandName)
|
|
50
|
+
if (existing && existing !== handler) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`A different handler is already registered for command "${commandName}". ` +
|
|
53
|
+
`Duplicate command handler subscriptions are not allowed.`,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
handlers.set(commandName, handler)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { QueryBus } from "./query-bus.js"
|
|
2
|
+
import type { QueryMessage } from "./message.js"
|
|
3
|
+
import type { SubscriptionQueryResult, UpdateHandler } from "./subscription-query.js"
|
|
4
|
+
import { createUpdateHandler, runAfterCommitOrImmediately } from "./subscription-query.js"
|
|
5
|
+
import { runInUoW } from "./unit-of-work.js"
|
|
6
|
+
import { qualifiedNameToString } from "@kronos-ts/common"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Simple in-process query bus with subscription query support.
|
|
10
|
+
*
|
|
11
|
+
* Direct queries are dispatched within a UnitOfWork via `runInUoW`.
|
|
12
|
+
* Subscription queries receive an initial result plus a stream of
|
|
13
|
+
* incremental updates emitted via `emitUpdate()`.
|
|
14
|
+
*
|
|
15
|
+
* Plan 03-04 (CTX-04 / D-34): the explicit `unitOfWorkFactory`
|
|
16
|
+
* parameter and branch are gone. `runInUoW` is the only codepath.
|
|
17
|
+
*
|
|
18
|
+
* Interceptor support is provided by wrapping with
|
|
19
|
+
* {@link createInterceptingQueryBus}.
|
|
20
|
+
*/
|
|
21
|
+
export function createSimpleQueryBus(): QueryBus {
|
|
22
|
+
const handlers = new Map<string, (message: QueryMessage) => Promise<unknown>>()
|
|
23
|
+
|
|
24
|
+
// Active subscription query handlers, keyed by query identifier
|
|
25
|
+
const subscriptions = new Map<string, UpdateHandler>()
|
|
26
|
+
|
|
27
|
+
const bus: QueryBus = {
|
|
28
|
+
async query(message: QueryMessage): Promise<unknown> {
|
|
29
|
+
const key = qualifiedNameToString(message.name)
|
|
30
|
+
const handler = handlers.get(key)
|
|
31
|
+
if (!handler) {
|
|
32
|
+
throw new Error(`No handler registered for query "${key}"`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Plan 03-01 (D-32) / Plan 03-04 (CTX-04): mirrors
|
|
36
|
+
// simple-command-bus.dispatch. ALS-aware nesting; primary dispatch
|
|
37
|
+
// creates a new UoW.
|
|
38
|
+
return runInUoW(message.metadata, () => handler(message))
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
subscribe(
|
|
42
|
+
queryName: string,
|
|
43
|
+
handler: (message: QueryMessage) => Promise<unknown>,
|
|
44
|
+
) {
|
|
45
|
+
const existing = handlers.get(queryName)
|
|
46
|
+
if (existing && existing !== handler) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`A different handler is already registered for query "${queryName}". ` +
|
|
49
|
+
`Duplicate query handler subscriptions are not allowed.`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
handlers.set(queryName, handler)
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
subscriptionQuery(message: QueryMessage, bufferSize?: number): SubscriptionQueryResult {
|
|
56
|
+
const queryId = message.identifier
|
|
57
|
+
|
|
58
|
+
if (subscriptions.has(queryId)) {
|
|
59
|
+
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const updateHandler = createUpdateHandler(message, bufferSize)
|
|
63
|
+
subscriptions.set(queryId, updateHandler)
|
|
64
|
+
|
|
65
|
+
const initialResult = bus.query(message)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
initialResult,
|
|
69
|
+
updates: updateHandler.iterable,
|
|
70
|
+
close: () => {
|
|
71
|
+
subscriptions.delete(queryId)
|
|
72
|
+
updateHandler.complete()
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
subscribeToUpdates(message: QueryMessage, bufferSize?: number): AsyncIterable<unknown> & { close(): void } {
|
|
78
|
+
const queryId = message.identifier
|
|
79
|
+
|
|
80
|
+
if (subscriptions.has(queryId)) {
|
|
81
|
+
throw new Error(`Subscription query already registered for identifier "${queryId}"`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const updateHandler = createUpdateHandler(message, bufferSize)
|
|
85
|
+
subscriptions.set(queryId, updateHandler)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
[Symbol.asyncIterator]: () => updateHandler.iterable[Symbol.asyncIterator](),
|
|
89
|
+
close: () => {
|
|
90
|
+
subscriptions.delete(queryId)
|
|
91
|
+
updateHandler.complete()
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async emitUpdate(
|
|
97
|
+
queryName: string,
|
|
98
|
+
filter: (queryPayload: unknown) => boolean,
|
|
99
|
+
update: unknown,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
runAfterCommitOrImmediately(() => {
|
|
102
|
+
for (const [id, handler] of subscriptions) {
|
|
103
|
+
if (!handler.active) {
|
|
104
|
+
subscriptions.delete(id)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const handlerQueryName = qualifiedNameToString(handler.query.name)
|
|
109
|
+
if (handlerQueryName !== queryName) continue
|
|
110
|
+
if (!filter(handler.query.payload)) continue
|
|
111
|
+
|
|
112
|
+
const accepted = handler.offer(update)
|
|
113
|
+
if (!accepted) {
|
|
114
|
+
handler.completeExceptionally(
|
|
115
|
+
new Error("Subscription query update buffer overflow"),
|
|
116
|
+
)
|
|
117
|
+
subscriptions.delete(id)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async completeSubscription(
|
|
124
|
+
queryName: string,
|
|
125
|
+
filter?: (queryPayload: unknown) => boolean,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
runAfterCommitOrImmediately(() => {
|
|
128
|
+
for (const [id, handler] of subscriptions) {
|
|
129
|
+
const handlerQueryName = qualifiedNameToString(handler.query.name)
|
|
130
|
+
if (handlerQueryName !== queryName) continue
|
|
131
|
+
if (filter && !filter(handler.query.payload)) continue
|
|
132
|
+
|
|
133
|
+
handler.complete()
|
|
134
|
+
subscriptions.delete(id)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async completeSubscriptionExceptionally(
|
|
140
|
+
queryName: string,
|
|
141
|
+
error: Error,
|
|
142
|
+
filter?: (queryPayload: unknown) => boolean,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
runAfterCommitOrImmediately(() => {
|
|
145
|
+
for (const [id, handler] of subscriptions) {
|
|
146
|
+
const handlerQueryName = qualifiedNameToString(handler.query.name)
|
|
147
|
+
if (handlerQueryName !== queryName) continue
|
|
148
|
+
if (filter && !filter(handler.query.payload)) continue
|
|
149
|
+
|
|
150
|
+
handler.completeExceptionally(error)
|
|
151
|
+
subscriptions.delete(id)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return bus
|
|
158
|
+
}
|