@kronos-ts/messaging 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/command-handler.d.ts +15 -15
  2. package/dist/command-handler.d.ts.map +1 -1
  3. package/dist/command-handler.js.map +1 -1
  4. package/dist/command-handling-module.js +2 -2
  5. package/dist/command-handling-module.js.map +1 -1
  6. package/dist/dead-lettering-handler.js +1 -1
  7. package/dist/dead-lettering-handler.js.map +1 -1
  8. package/dist/event-handler.d.ts +5 -5
  9. package/dist/event-handler.d.ts.map +1 -1
  10. package/dist/event-handler.js +2 -2
  11. package/dist/event-handler.js.map +1 -1
  12. package/dist/event-processor-builder.d.ts +3 -3
  13. package/dist/event-processor-builder.js +3 -3
  14. package/dist/gateway.d.ts +7 -5
  15. package/dist/gateway.d.ts.map +1 -1
  16. package/dist/gateway.js +9 -12
  17. package/dist/gateway.js.map +1 -1
  18. package/dist/handler.d.ts +13 -13
  19. package/dist/handler.d.ts.map +1 -1
  20. package/dist/handler.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/intercepting-command-bus.d.ts +1 -1
  25. package/dist/intercepting-command-bus.d.ts.map +1 -1
  26. package/dist/intercepting-command-bus.js +3 -4
  27. package/dist/intercepting-command-bus.js.map +1 -1
  28. package/dist/intercepting-query-bus.d.ts +1 -1
  29. package/dist/intercepting-query-bus.d.ts.map +1 -1
  30. package/dist/intercepting-query-bus.js +3 -4
  31. package/dist/intercepting-query-bus.js.map +1 -1
  32. package/dist/interceptor.d.ts +18 -3
  33. package/dist/interceptor.d.ts.map +1 -1
  34. package/dist/message.d.ts +4 -0
  35. package/dist/message.d.ts.map +1 -1
  36. package/dist/query-handler.d.ts +5 -5
  37. package/dist/query-handler.d.ts.map +1 -1
  38. package/dist/query-handler.js +2 -2
  39. package/dist/query-handler.js.map +1 -1
  40. package/dist/query-handling-module.js +1 -1
  41. package/dist/query-handling-module.js.map +1 -1
  42. package/dist/simple-command-bus.d.ts +11 -4
  43. package/dist/simple-command-bus.d.ts.map +1 -1
  44. package/dist/simple-command-bus.js +16 -10
  45. package/dist/simple-command-bus.js.map +1 -1
  46. package/dist/streaming-event-processor.d.ts +2 -0
  47. package/dist/streaming-event-processor.d.ts.map +1 -1
  48. package/dist/streaming-event-processor.js +22 -3
  49. package/dist/streaming-event-processor.js.map +1 -1
  50. package/dist/subscribing-event-processor.js +1 -1
  51. package/dist/subscribing-event-processor.js.map +1 -1
  52. package/dist/tracking-event-processor.d.ts.map +1 -1
  53. package/dist/tracking-event-processor.js +11 -1
  54. package/dist/tracking-event-processor.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/command-handler.ts +15 -28
  57. package/src/command-handling-module.ts +2 -2
  58. package/src/dead-lettering-handler.ts +1 -1
  59. package/src/event-handler.ts +5 -8
  60. package/src/event-processor-builder.ts +3 -3
  61. package/src/gateway.ts +14 -22
  62. package/src/handler.ts +13 -22
  63. package/src/index.ts +1 -1
  64. package/src/intercepting-command-bus.ts +7 -6
  65. package/src/intercepting-query-bus.ts +7 -6
  66. package/src/interceptor.ts +21 -4
  67. package/src/message.ts +5 -0
  68. package/src/query-handler.ts +5 -5
  69. package/src/query-handling-module.ts +1 -1
  70. package/src/simple-command-bus.ts +17 -11
  71. package/src/streaming-event-processor.ts +29 -8
  72. package/src/subscribing-event-processor.ts +1 -1
  73. package/src/tracking-event-processor.ts +11 -1
@@ -1,6 +1,6 @@
1
1
  import type { z } from "zod"
2
- import type { Metadata } from "@kronos-ts/common"
3
2
  import type { EventDescriptor } from "./descriptor.js"
3
+ import type { SequencedEventMessage } from "./message.js"
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // Singular factory — mirrors commandHandler / queryHandler.
@@ -16,18 +16,15 @@ import type { EventDescriptor } from "./descriptor.js"
16
16
  export interface EventHandlerDefinition<P extends z.ZodType = z.ZodType> {
17
17
  readonly kind: "event-handler"
18
18
  readonly descriptor: EventDescriptor<P>
19
- readonly handler: (
20
- event: z.infer<P>,
21
- metadata: Metadata,
22
- ) => Promise<void> | void
19
+ readonly handler: (message: SequencedEventMessage<z.infer<P>>) => Promise<void> | void
23
20
  }
24
21
 
25
22
  /**
26
23
  * Defines a singular event handler.
27
24
  *
28
25
  * ```
29
- * const onCourseCreated = eventHandler(CourseCreated, async (event, metadata) => {
30
- * await db.courses.insert({ id: event.courseId, name: event.name })
26
+ * const onCourseCreated = eventHandler(CourseCreated, async ({ payload, metadata, timestamp }) => {
27
+ * await db.courses.insert({ id: payload.courseId, name: payload.name, createdAt: timestamp })
31
28
  * })
32
29
  * ```
33
30
  *
@@ -38,7 +35,7 @@ export interface EventHandlerDefinition<P extends z.ZodType = z.ZodType> {
38
35
  */
39
36
  export function eventHandler<P extends z.ZodType>(
40
37
  descriptor: EventDescriptor<P>,
41
- handler: (event: z.infer<P>, metadata: Metadata) => Promise<void> | void,
38
+ handler: (message: SequencedEventMessage<z.infer<P>>) => Promise<void> | void,
42
39
  ): EventHandlerDefinition<P> {
43
40
  return { kind: "event-handler", descriptor, handler }
44
41
  }
@@ -60,8 +60,8 @@ export type EventProcessorModule = TrackingProcessorModule | SubscribingProcesso
60
60
  * position via a token store, and support replay/reset.
61
61
  *
62
62
  * ```typescript
63
- * const onCreated = eventHandler(CourseCreated, async (e) => { ... })
64
- * const onCapChanged = eventHandler(CourseCapacityChanged, async (e) => { ... })
63
+ * const onCreated = eventHandler(CourseCreated, async ({ payload: e }) => { ... })
64
+ * const onCapChanged = eventHandler(CourseCapacityChanged, async ({ payload: e }) => { ... })
65
65
  *
66
66
  * trackingProcessor("course-projection")
67
67
  * .eventHandlers(onCreated, onCapChanged)
@@ -186,7 +186,7 @@ export class TrackingProcessorBuilder {
186
186
  * as they are appended. No token store, no position tracking, no replay.
187
187
  *
188
188
  * ```typescript
189
- * const onNotification = eventHandler(NotificationRaised, async (e) => { ... })
189
+ * const onNotification = eventHandler(NotificationRaised, async ({ payload: e }) => { ... })
190
190
  *
191
191
  * subscribingProcessor("notifications")
192
192
  * .eventHandlers(onNotification)
package/src/gateway.ts CHANGED
@@ -74,32 +74,24 @@ export interface QueryGateway {
74
74
  /**
75
75
  * Creates a command gateway backed by a command bus.
76
76
  *
77
- * Plan 03-04 (CTX-04 / D-34): the optional `unitOfWorkRunner` lets the
78
- * configurer inject a transactional wrapper (`transactionalUnitOfWorkFactory`)
79
- * around the dispatch boundary. Defaults to `runInNewUoW` — preserves the
80
- * Plan 03-01 contract that every gateway call starts a fresh UoW.
77
+ * AF5-aligned (CLAUDE.md command model): the gateway is a thin message-builder
78
+ * and does NOT establish a UnitOfWork. The command bus owns the single
79
+ * per-command UoW (and, via the configured `unitOfWorkFactory`, its
80
+ * transaction) see `createSimpleCommandBus`. Dispatch interceptors run on
81
+ * the message before it crosses into that UoW; the dispatch-side hook channel
82
+ * is ALS, not a threaded ProcessingContext.
81
83
  */
82
- export function createCommandGateway(
83
- bus: CommandBus,
84
- unitOfWorkRunner: UoWRunner = runInNewUoW,
85
- ): CommandGateway {
84
+ export function createCommandGateway(bus: CommandBus): CommandGateway {
86
85
  return {
87
86
  async send(descriptor, payload, metadata) {
88
87
  const resolvedMetadata = metadata ?? emptyMetadata()
89
- // Plan 03-01 (D-32) / Plan 03-03 (CTX-01): gateways always start a new
90
- // UoW. The bus.dispatch call below will detect the ALS state we just
91
- // established (via runInUoW in simple-command-bus) and reuse it — so
92
- // this is the single UoW boundary for the dispatch chain. No
93
- // ProcessingContext parameter is threaded.
94
- return unitOfWorkRunner(resolvedMetadata, () =>
95
- bus.dispatch({
96
- identifier: generateIdentifier(),
97
- name: descriptor.name,
98
- payload,
99
- metadata: resolvedMetadata,
100
- timestamp: Date.now(),
101
- }) as Promise<any>,
102
- ) as any
88
+ return bus.dispatch({
89
+ identifier: generateIdentifier(),
90
+ name: descriptor.name,
91
+ payload,
92
+ metadata: resolvedMetadata,
93
+ timestamp: Date.now(),
94
+ }) as any
103
95
  },
104
96
  }
105
97
  }
package/src/handler.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { z } from "zod"
2
- import type { Metadata } from "@kronos-ts/common"
3
2
  import type {
4
3
  CommandDescriptor,
5
4
  EventDescriptor,
6
5
  QueryDescriptor,
7
6
  } from "./descriptor.js"
7
+ import type { EventMessage, QueryMessage, SequencedEventMessage } from "./message.js"
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // Handler context shapes — DELETED (Plan 04-02, D-41)
@@ -22,10 +22,7 @@ import type {
22
22
  export interface EventHandlerRegistration<P extends z.ZodType = z.ZodType> {
23
23
  readonly kind: "event-handler"
24
24
  readonly descriptor: EventDescriptor<P>
25
- readonly handler: (
26
- event: z.infer<P>,
27
- metadata: Metadata,
28
- ) => Promise<void> | void
25
+ readonly handler: (message: SequencedEventMessage<z.infer<P>>) => Promise<void> | void
29
26
  }
30
27
 
31
28
  /**
@@ -40,7 +37,7 @@ export interface EvolverRegistration<
40
37
  > {
41
38
  readonly kind: "evolver"
42
39
  readonly descriptor: EventDescriptor<P>
43
- readonly evolve: (state: S, event: z.infer<P>, id: unknown) => S | Promise<S>
40
+ readonly evolve: (state: S, message: EventMessage<z.infer<P>>) => S | Promise<S>
44
41
  }
45
42
 
46
43
  /** A paired query descriptor + handler function, with result type on the handler. */
@@ -50,10 +47,7 @@ export interface QueryHandlerRegistration<
50
47
  > {
51
48
  readonly kind: "query-handler"
52
49
  readonly descriptor: QueryDescriptor<Q>
53
- readonly handler: (
54
- query: z.infer<Q>,
55
- metadata: Metadata,
56
- ) => Promise<R> | R
50
+ readonly handler: (message: QueryMessage<z.infer<Q>>) => Promise<R> | R
57
51
  }
58
52
 
59
53
  // ---------------------------------------------------------------------------
@@ -65,13 +59,13 @@ export interface QueryHandlerRegistration<
65
59
  * Pairs a descriptor with its handler for use in handler/evolve arrays.
66
60
  *
67
61
  * Usage:
68
- * - Event handlers: `on(CourseCreated, async (event, ctx) => { ... })`
69
- * - Query handlers: `on(GetCourse, async (query, ctx) => { return { ... } })`
70
- * - Evolvers: `on(CourseCreated, (state, event) => ({ ...state, name: event.name }))`
62
+ * - Event handlers: `on(CourseCreated, async ({ payload, metadata }) => { ... })`
63
+ * - Query handlers: `on(GetCourse, async ({ payload, metadata }) => { return { ... } })`
64
+ * - Evolvers: `on(CourseCreated, (state, { payload }) => ({ ...state, name: payload.name }))`
71
65
  *
72
66
  * The overload is resolved by the descriptor kind and how many arguments
73
- * the callback declares. Evolvers receive `(state, event)` or `(state, event, id)`,
74
- * while event handlers receive `(event, context)`.
67
+ * the callback declares. Event and query handlers receive the full typed message.
68
+ * Evolvers receive the current state plus the full typed event message.
75
69
  *
76
70
  * In practice the distinction is enforced by the array type:
77
71
  * - `evolve: [on(...)]` expects `EvolverRegistration`
@@ -81,22 +75,19 @@ export interface QueryHandlerRegistration<
81
75
  // Overload: evolver (event descriptor + state evolve function)
82
76
  export function on<S, P extends z.ZodType>(
83
77
  descriptor: EventDescriptor<P>,
84
- evolve: (state: S, event: z.infer<P>, id: unknown) => S | Promise<S>,
78
+ evolve: (state: S, message: EventMessage<z.infer<P>>) => S | Promise<S>,
85
79
  ): EvolverRegistration<S, P>
86
80
 
87
81
  // Overload: event handler (event descriptor + handler function)
88
82
  export function on<P extends z.ZodType>(
89
83
  descriptor: EventDescriptor<P>,
90
- handler: (event: z.infer<P>, metadata: Metadata) => Promise<void> | void,
84
+ handler: (message: SequencedEventMessage<z.infer<P>>) => Promise<void> | void,
91
85
  ): EventHandlerRegistration<P>
92
86
 
93
87
  // Overload: query handler
94
88
  export function on<Q extends z.ZodType, R>(
95
89
  descriptor: QueryDescriptor<Q>,
96
- handler: (
97
- query: z.infer<Q>,
98
- metadata: Metadata,
99
- ) => Promise<R> | R,
90
+ handler: (message: QueryMessage<z.infer<Q>>) => Promise<R> | R,
100
91
  ): QueryHandlerRegistration<Q, R>
101
92
 
102
93
  export function on(
@@ -127,7 +118,7 @@ export function on(
127
118
  */
128
119
  export function onEvent<S, P extends z.ZodType>(
129
120
  descriptor: EventDescriptor<P>,
130
- evolve: (state: S, event: z.infer<P>, id: unknown) => S | Promise<S>,
121
+ evolve: (state: S, message: EventMessage<z.infer<P>>) => S | Promise<S>,
131
122
  ): EvolverRegistration<S, P> {
132
123
  return { kind: "evolver", descriptor, evolve }
133
124
  }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export {
4
4
  type CommandMessage,
5
5
  type CommandResultMessage,
6
6
  type EventMessage,
7
+ type SequencedEventMessage,
7
8
  type QueryMessage,
8
9
  } from "./message.js"
9
10
 
@@ -375,4 +376,3 @@ export {
375
376
 
376
377
  // Namespace factory
377
378
  export { withNamespace } from "./with-namespace.js"
378
-
@@ -15,10 +15,10 @@ export function createInterceptingCommandBus(
15
15
  /** Register a dispatch interceptor. Returns an unsubscribe function. */
16
16
  registerDispatchInterceptor(interceptor: DispatchInterceptor<CommandMessage>): () => void
17
17
  /** Register a handler interceptor. Returns an unsubscribe function. */
18
- registerHandlerInterceptor(interceptor: HandlerInterceptor): () => void
18
+ registerHandlerInterceptor(interceptor: HandlerInterceptor<CommandMessage>): () => void
19
19
  } {
20
20
  const dispatchInterceptors: Array<DispatchInterceptor<CommandMessage>> = []
21
- const handlerInterceptors: Array<HandlerInterceptor> = []
21
+ const handlerInterceptors: Array<HandlerInterceptor<CommandMessage>> = []
22
22
 
23
23
  return {
24
24
  async dispatch(message: CommandMessage): Promise<unknown> {
@@ -35,20 +35,21 @@ export function createInterceptingCommandBus(
35
35
  commandName: string,
36
36
  handler: (message: CommandMessage) => Promise<unknown>,
37
37
  ) {
38
- // Wrap the handler with handler interceptors
39
38
  const wrappedHandler = (message: CommandMessage) => {
40
39
  if (handlerInterceptors.length === 0) {
41
40
  return handler(message)
42
41
  }
43
42
 
44
- let chain = () => handler(message)
43
+ let chain = (currentMessage: CommandMessage) => handler(currentMessage)
45
44
  for (let i = handlerInterceptors.length - 1; i >= 0; i--) {
46
45
  const interceptor = handlerInterceptors[i]!
47
46
  const next = chain
48
- chain = () => interceptor(message, next)
47
+ chain = (currentMessage: CommandMessage) =>
48
+ interceptor(currentMessage, (replacementMessage) =>
49
+ next(replacementMessage ?? currentMessage))
49
50
  }
50
51
 
51
- return chain()
52
+ return chain(message)
52
53
  }
53
54
 
54
55
  delegate.subscribe(commandName, wrappedHandler)
@@ -17,10 +17,10 @@ export function createInterceptingQueryBus(
17
17
  /** Register a dispatch interceptor. Returns an unsubscribe function. */
18
18
  registerDispatchInterceptor(interceptor: DispatchInterceptor<QueryMessage>): () => void
19
19
  /** Register a handler interceptor. Returns an unsubscribe function. */
20
- registerHandlerInterceptor(interceptor: HandlerInterceptor): () => void
20
+ registerHandlerInterceptor(interceptor: HandlerInterceptor<QueryMessage>): () => void
21
21
  } {
22
22
  const dispatchInterceptors: Array<DispatchInterceptor<QueryMessage>> = []
23
- const handlerInterceptors: Array<HandlerInterceptor> = []
23
+ const handlerInterceptors: Array<HandlerInterceptor<QueryMessage>> = []
24
24
 
25
25
  return {
26
26
  async query(message: QueryMessage): Promise<unknown> {
@@ -36,20 +36,21 @@ export function createInterceptingQueryBus(
36
36
  queryName: string,
37
37
  handler: (message: QueryMessage) => Promise<unknown>,
38
38
  ) {
39
- // Wrap the handler with handler interceptors
40
39
  const wrappedHandler = (message: QueryMessage) => {
41
40
  if (handlerInterceptors.length === 0) {
42
41
  return handler(message)
43
42
  }
44
43
 
45
- let chain = () => handler(message)
44
+ let chain = (currentMessage: QueryMessage) => handler(currentMessage)
46
45
  for (let i = handlerInterceptors.length - 1; i >= 0; i--) {
47
46
  const interceptor = handlerInterceptors[i]!
48
47
  const next = chain
49
- chain = () => interceptor(message, next)
48
+ chain = (currentMessage: QueryMessage) =>
49
+ interceptor(currentMessage, (replacementMessage) =>
50
+ next(replacementMessage ?? currentMessage))
50
51
  }
51
52
 
52
- return chain()
53
+ return chain(message)
53
54
  }
54
55
 
55
56
  delegate.subscribe(queryName, wrappedHandler)
@@ -1,4 +1,3 @@
1
- import type { Metadata } from "@kronos-ts/common"
2
1
  import type { Message } from "./message.js"
3
2
 
4
3
  /**
@@ -38,11 +37,29 @@ export interface DispatchInterceptor<M extends Message = Message> {
38
37
  * accessors (`getResource` / `setResource`) — no `ProcessingContext`
39
38
  * parameter is threaded.
40
39
  *
40
+ * The first argument is the full message object. Prefer keeping it as
41
+ * `message` when transforming or inspecting broad message details:
42
+ *
43
+ * ```
44
+ * app.handlerInterceptor(async (message, next) => {
45
+ * const { payload, metadata, timestamp } = message
46
+ * return next({
47
+ * ...message,
48
+ * metadata: { ...metadata, tenantId: "tenant-1" },
49
+ * })
50
+ * })
51
+ * ```
52
+ *
41
53
  * The `next` function calls the next interceptor in the chain, or the
42
- * actual handler if this is the last interceptor.
54
+ * actual handler if this is the last interceptor. Call `next()` to proceed
55
+ * with the current message, or `next(replacementMessage)` to proceed with a
56
+ * transformed message.
43
57
  *
44
58
  * To skip handling entirely, don't call `next()` and return a result directly.
45
59
  */
46
- export interface HandlerInterceptor<R = unknown> {
47
- (message: Message, next: () => Promise<R>): Promise<R>
60
+ export interface HandlerInterceptor<
61
+ M extends Message = Message,
62
+ R = unknown,
63
+ > {
64
+ (message: M, next: (message?: M) => Promise<R>): Promise<R>
48
65
  }
package/src/message.ts CHANGED
@@ -35,6 +35,11 @@ export interface EventMessage<P = unknown> extends Message<P> {
35
35
  readonly tags: ReadonlyArray<{ readonly key: string; readonly value: string }>
36
36
  }
37
37
 
38
+ export interface SequencedEventMessage<P = unknown> extends EventMessage<P> {
39
+ /** Stream position when the source has one; absent for push-only delivery. */
40
+ readonly sequence?: bigint
41
+ }
42
+
38
43
  /**
39
44
  * A query message — dispatched to handler(s) that can answer it.
40
45
  */
@@ -1,6 +1,6 @@
1
1
  import type { z } from "zod"
2
- import type { Metadata } from "@kronos-ts/common"
3
2
  import type { QueryDescriptor } from "./descriptor.js"
3
+ import type { QueryMessage } from "./message.js"
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // Singular factory — mirrors commandHandler / eventHandler.
@@ -23,15 +23,15 @@ export interface QueryHandlerDefinition<
23
23
  > {
24
24
  readonly kind: "query-handler"
25
25
  readonly descriptor: QueryDescriptor<Q, z.ZodType | undefined>
26
- readonly handler: (query: z.infer<Q>, metadata: Metadata) => Promise<R> | R
26
+ readonly handler: (message: QueryMessage<z.infer<Q>>) => Promise<R> | R
27
27
  }
28
28
 
29
29
  /**
30
30
  * Defines a singular query handler.
31
31
  *
32
32
  * ```
33
- * const getCourseView = queryHandler(GetCourseView, async (q, metadata) => {
34
- * const view = courseViews.get(q.courseId)
33
+ * const getCourseView = queryHandler(GetCourseView, async ({ payload, metadata }) => {
34
+ * const view = courseViews.get(payload.courseId)
35
35
  * if (!view) throw new Error("not found")
36
36
  * return view
37
37
  * })
@@ -43,7 +43,7 @@ export interface QueryHandlerDefinition<
43
43
  */
44
44
  export function queryHandler<Q extends z.ZodType, R>(
45
45
  descriptor: QueryDescriptor<Q, z.ZodType | undefined>,
46
- handler: (query: z.infer<Q>, metadata: Metadata) => Promise<R> | R,
46
+ handler: (message: QueryMessage<z.infer<Q>>) => Promise<R> | R,
47
47
  ): QueryHandlerDefinition<Q, R> {
48
48
  return { kind: "query-handler", descriptor, handler }
49
49
  }
@@ -31,7 +31,7 @@ export function registerQueryHandlersNatively(
31
31
  for (const reg of handlers) {
32
32
  const queryName = qualifiedNameToString(reg.descriptor.name)
33
33
  let invocation = async (message: QueryMessage) =>
34
- reg.handler(message.payload, message.metadata)
34
+ reg.handler(message)
35
35
  if (deps.handlerEnhancer) {
36
36
  invocation = deps.handlerEnhancer.wrapHandler(invocation, {
37
37
  messageType: "query",
@@ -1,6 +1,6 @@
1
1
  import type { CommandBus } from "./command-bus.js"
2
2
  import type { CommandMessage } from "./message.js"
3
- import { runInNewUoW } from "./unit-of-work.js"
3
+ import { runInNewUoW, type UoWRunner } from "./unit-of-work.js"
4
4
  import { qualifiedNameToString } from "@kronos-ts/common"
5
5
 
6
6
  /**
@@ -16,14 +16,20 @@ import { qualifiedNameToString } from "@kronos-ts/common"
16
16
  * by sharing a transaction. DCB read-set / append-condition merging
17
17
  * happens only WITHIN a single handler's UnitOfWork.
18
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.
19
+ * The handler runs through `unitOfWorkRunner` by default `runInNewUoW`, but
20
+ * the configurer injects the resolved `unitOfWorkFactory` slot (e.g. a
21
+ * transactional runner from a storage extension) so the per-command UoW
22
+ * carries whatever transaction that backend provides. This mirrors the
23
+ * distributed command buses (kronosdb / axon-server), which already run
24
+ * handlers through the configured runner. The runner is always built on
25
+ * `runInNewUoW`, so a command — primary OR nested via `send()` — still gets
26
+ * its own fresh UoW (and its own independent transaction); composition does
27
+ * not change AF5 isolation, only whether that fresh UoW has a transaction.
22
28
  *
23
29
  * Interceptor support is provided by wrapping with
24
30
  * {@link createInterceptingCommandBus}.
25
31
  */
26
- export function createSimpleCommandBus(): CommandBus {
32
+ export function createSimpleCommandBus(unitOfWorkRunner: UoWRunner = runInNewUoW): CommandBus {
27
33
  const handlers = new Map<string, (message: CommandMessage) => Promise<unknown>>()
28
34
 
29
35
  return {
@@ -34,12 +40,12 @@ export function createSimpleCommandBus(): CommandBus {
34
40
  throw new Error(`No handler registered for command "${key}"`)
35
41
  }
36
42
 
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
+ // AF5 parity: every command gets its own fresh UnitOfWork (the runner is
44
+ // built on runInNewUoW), even when dispatched from inside another
45
+ // handler. Dispatch interceptors have already run in the caller's context
46
+ // (the intercepting bus wraps this one), so correlation data is carried
47
+ // on `message.metadata` before we cross into the new UoW.
48
+ return unitOfWorkRunner(message.metadata, () => handler(message))
43
49
  },
44
50
 
45
51
  subscribe(
@@ -75,6 +75,8 @@ export interface StreamingEventProcessorOptions {
75
75
  unitOfWorkRunner?: UoWRunner
76
76
  tokenStore?: TokenStore
77
77
  batchSize?: number
78
+ /** Delay before retrying after a batch failure, in ms. Backs off to avoid hot-looping a deterministic failure. */
79
+ errorBackoffMs?: number
78
80
  errorHandler?: EventProcessingErrorHandler
79
81
  /** Optional handler enhancer applied to all event handlers at setup time. */
80
82
  handlerEnhancer?: HandlerEnhancerDefinition
@@ -96,6 +98,7 @@ export function createStreamingEventProcessor(
96
98
  unitOfWorkRunner = runInNewUoW,
97
99
  tokenStore,
98
100
  batchSize = 100,
101
+ errorBackoffMs = 1000,
99
102
  errorHandler = loggingErrorHandler(name),
100
103
  handlerEnhancer,
101
104
  onReset,
@@ -158,6 +161,22 @@ export function createStreamingEventProcessor(
158
161
  } catch (err) {
159
162
  lastError = err instanceof Error ? err : new Error(String(err))
160
163
  console.error(`Event processor "${name}" error:`, err)
164
+ // Realign the live stream to the committed checkpoint. During batch
165
+ // accumulation the stream cursor (and any read-ahead buffer) advanced
166
+ // past this batch, but `token` was NOT advanced — the failing UnitOfWork
167
+ // never reached PREPARE_COMMIT. Closing discards the stream's buffer so
168
+ // the next cycle reopens at token.position() and re-reads — and thus
169
+ // redelivers — the failed batch. Without this the stream cursor outruns
170
+ // the checkpoint and the failed events are skipped until a restart.
171
+ // Mirrors Axon's close-and-reopen-from-token recovery. Back off before
172
+ // retrying so a deterministically failing handler can't hot-loop.
173
+ stream?.close()
174
+ stream = null
175
+ if (isRunning) {
176
+ if (processTimer !== null) clearTimeout(processTimer)
177
+ processTimer = setTimeout(processAvailable, errorBackoffMs)
178
+ }
179
+ return
161
180
  } finally {
162
181
  processing = false
163
182
  }
@@ -170,24 +189,26 @@ export function createStreamingEventProcessor(
170
189
  }
171
190
 
172
191
  async function processFromStream() {
173
- if (!stream) return
192
+ // Lazily (re)open the stream at the committed token. The error path nulls
193
+ // `stream` so processing resumes from the checkpoint, not a stale cursor.
194
+ if (!stream) openStream()
174
195
 
175
196
  // Check for stream errors — reopen if needed
176
- const streamError = stream.error()
197
+ const streamError = stream!.error()
177
198
  if (streamError) {
178
199
  console.error(`Event processor "${name}": stream error, reopening:`, streamError)
179
- stream.close()
200
+ stream!.close()
180
201
  stream = null
181
202
  openStream()
182
203
  return
183
204
  }
184
205
 
185
206
  const batch: SequencedEvent[] = []
186
- let event = stream.next()
207
+ let event = stream!.next()
187
208
  while (event && batch.length < batchSize) {
188
209
  batch.push(event)
189
- if (batch.length < batchSize && stream.hasNextAvailable()) {
190
- event = stream.next()
210
+ if (batch.length < batchSize && stream!.hasNextAvailable()) {
211
+ event = stream!.next()
191
212
  } else {
192
213
  break
193
214
  }
@@ -196,7 +217,7 @@ export function createStreamingEventProcessor(
196
217
  if (batch.length > 0) {
197
218
  caughtUp = false
198
219
  await processBatch(batch)
199
- if (stream.hasNextAvailable()) {
220
+ if (stream!.hasNextAvailable()) {
200
221
  scheduleImmediate()
201
222
  }
202
223
  } else {
@@ -249,7 +270,7 @@ export function createStreamingEventProcessor(
249
270
 
250
271
  for (const reg of handlers) {
251
272
  try {
252
- await reg.handler(event.payload, event.metadata)
273
+ await reg.handler({ ...event, sequence: sequencedEvent.sequence })
253
274
  } catch (err) {
254
275
  await errorHandler.handleError(err, eventName, sequencedEvent.sequence)
255
276
  }
@@ -135,7 +135,7 @@ export function createSubscribingEventProcessor(
135
135
 
136
136
  for (const reg of handlers) {
137
137
  try {
138
- await reg.handler(event.payload, event.metadata)
138
+ await reg.handler(event)
139
139
  } catch (err) {
140
140
  // SubscribingEventProcessor doesn't have position tracking,
141
141
  // so pass -1n as position indicator
@@ -224,6 +224,16 @@ export function createTrackingEventProcessor(
224
224
  }
225
225
  } catch (err) {
226
226
  console.error(`Event processor "${name}" error during poll:`, err)
227
+ // Realign the live stream to the committed checkpoint. During batch
228
+ // accumulation the stream cursor (and any read-ahead buffer) advanced
229
+ // past this batch, but `token` was NOT advanced — the failing UnitOfWork
230
+ // never reached PREPARE_COMMIT. Closing discards the stream's buffer so
231
+ // the next poll reopens at token.position() and re-reads — and thus
232
+ // redelivers — the failed batch. Without this the stream cursor outruns
233
+ // the checkpoint and the failed events are skipped until a restart.
234
+ // Mirrors Axon's close-and-reopen-from-token recovery.
235
+ stream?.close()
236
+ stream = null
227
237
  if (isRunning) pollTimer = setTimeout(poll, pollingIntervalMs * 2)
228
238
  } finally {
229
239
  processing = false
@@ -268,7 +278,7 @@ export function createTrackingEventProcessor(
268
278
 
269
279
  for (const reg of handlers) {
270
280
  try {
271
- await reg.handler(event.payload, event.metadata)
281
+ await reg.handler({ ...event, sequence: sequencedEvent.sequence })
272
282
  } catch (err) {
273
283
  await errorHandler.handleError(err, eventName, sequencedEvent.sequence)
274
284
  }