@kronos-ts/messaging 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) 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/emit-update.d.ts +2 -1
  9. package/dist/emit-update.d.ts.map +1 -1
  10. package/dist/emit-update.js.map +1 -1
  11. package/dist/event-handler.d.ts +5 -5
  12. package/dist/event-handler.d.ts.map +1 -1
  13. package/dist/event-handler.js +2 -2
  14. package/dist/event-handler.js.map +1 -1
  15. package/dist/event-processor-builder.d.ts +3 -3
  16. package/dist/event-processor-builder.js +3 -3
  17. package/dist/event-scheduler.d.ts +95 -0
  18. package/dist/event-scheduler.d.ts.map +1 -0
  19. package/dist/event-scheduler.js +47 -0
  20. package/dist/event-scheduler.js.map +1 -0
  21. package/dist/gateway.d.ts +7 -5
  22. package/dist/gateway.d.ts.map +1 -1
  23. package/dist/gateway.js +9 -12
  24. package/dist/gateway.js.map +1 -1
  25. package/dist/handler.d.ts +13 -13
  26. package/dist/handler.d.ts.map +1 -1
  27. package/dist/handler.js.map +1 -1
  28. package/dist/in-memory-event-scheduler.d.ts +45 -0
  29. package/dist/in-memory-event-scheduler.d.ts.map +1 -0
  30. package/dist/in-memory-event-scheduler.js +112 -0
  31. package/dist/in-memory-event-scheduler.js.map +1 -0
  32. package/dist/index.d.ts +5 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/intercepting-command-bus.d.ts +1 -1
  37. package/dist/intercepting-command-bus.d.ts.map +1 -1
  38. package/dist/intercepting-command-bus.js +3 -4
  39. package/dist/intercepting-command-bus.js.map +1 -1
  40. package/dist/intercepting-query-bus.d.ts +1 -1
  41. package/dist/intercepting-query-bus.d.ts.map +1 -1
  42. package/dist/intercepting-query-bus.js +3 -4
  43. package/dist/intercepting-query-bus.js.map +1 -1
  44. package/dist/interceptor.d.ts +18 -3
  45. package/dist/interceptor.d.ts.map +1 -1
  46. package/dist/message.d.ts +4 -0
  47. package/dist/message.d.ts.map +1 -1
  48. package/dist/query-bus.d.ts +8 -3
  49. package/dist/query-bus.d.ts.map +1 -1
  50. package/dist/query-handler.d.ts +5 -5
  51. package/dist/query-handler.d.ts.map +1 -1
  52. package/dist/query-handler.js +2 -2
  53. package/dist/query-handler.js.map +1 -1
  54. package/dist/query-handling-module.js +1 -1
  55. package/dist/query-handling-module.js.map +1 -1
  56. package/dist/simple-command-bus.d.ts +11 -4
  57. package/dist/simple-command-bus.d.ts.map +1 -1
  58. package/dist/simple-command-bus.js +16 -10
  59. package/dist/simple-command-bus.js.map +1 -1
  60. package/dist/simple-query-bus.d.ts.map +1 -1
  61. package/dist/simple-query-bus.js +4 -3
  62. package/dist/simple-query-bus.js.map +1 -1
  63. package/dist/streaming-event-processor.js +1 -1
  64. package/dist/streaming-event-processor.js.map +1 -1
  65. package/dist/subscribing-event-processor.js +1 -1
  66. package/dist/subscribing-event-processor.js.map +1 -1
  67. package/dist/subscription-filter.d.ts +43 -0
  68. package/dist/subscription-filter.d.ts.map +1 -0
  69. package/dist/subscription-filter.js +71 -0
  70. package/dist/subscription-filter.js.map +1 -0
  71. package/dist/tracking-event-processor.js +1 -1
  72. package/dist/tracking-event-processor.js.map +1 -1
  73. package/dist/transaction.d.ts +31 -0
  74. package/dist/transaction.d.ts.map +1 -1
  75. package/dist/transaction.js +80 -0
  76. package/dist/transaction.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/command-handler.ts +15 -28
  79. package/src/command-handling-module.ts +2 -2
  80. package/src/dead-lettering-handler.ts +1 -1
  81. package/src/emit-update.ts +3 -2
  82. package/src/event-handler.ts +5 -8
  83. package/src/event-processor-builder.ts +3 -3
  84. package/src/event-scheduler.ts +96 -0
  85. package/src/gateway.ts +14 -22
  86. package/src/handler.ts +13 -22
  87. package/src/in-memory-event-scheduler.ts +150 -0
  88. package/src/index.ts +23 -1
  89. package/src/intercepting-command-bus.ts +7 -6
  90. package/src/intercepting-query-bus.ts +11 -9
  91. package/src/interceptor.ts +21 -4
  92. package/src/message.ts +5 -0
  93. package/src/query-bus.ts +8 -3
  94. package/src/query-handler.ts +5 -5
  95. package/src/query-handling-module.ts +1 -1
  96. package/src/simple-command-bus.ts +17 -11
  97. package/src/simple-query-bus.ts +8 -6
  98. package/src/streaming-event-processor.ts +1 -1
  99. package/src/subscribing-event-processor.ts +1 -1
  100. package/src/subscription-filter.ts +85 -0
  101. package/src/tracking-event-processor.ts +1 -1
  102. package/src/transaction.ts +84 -0
@@ -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
  */
package/src/query-bus.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { QueryMessage } from "./message.js"
2
2
  import type { SubscriptionQueryResult } from "./subscription-query.js"
3
+ import type { SubscriptionFilter } from "./subscription-filter.js"
3
4
 
4
5
  /**
5
6
  * The query bus — low-level infrastructure for dispatching query messages.
@@ -43,10 +44,14 @@ export interface QueryBus {
43
44
  * Emit an update to all active subscription queries matching the filter.
44
45
  * When called within an active UnitOfWork (detected via ALS), the update is
45
46
  * deferred to AFTER_COMMIT.
47
+ *
48
+ * The filter can be either a function (local-only when a distributed bus is
49
+ * in use) or a structured `payloadEquals` predicate (crosses transports).
50
+ * See {@link SubscriptionFilter}.
46
51
  */
47
52
  emitUpdate(
48
53
  queryName: string,
49
- filter: (queryPayload: unknown) => boolean,
54
+ filter: SubscriptionFilter,
50
55
  update: unknown,
51
56
  ): Promise<void>
52
57
 
@@ -55,7 +60,7 @@ export interface QueryBus {
55
60
  */
56
61
  completeSubscription(
57
62
  queryName: string,
58
- filter?: (queryPayload: unknown) => boolean,
63
+ filter?: SubscriptionFilter,
59
64
  ): Promise<void>
60
65
 
61
66
  /**
@@ -64,6 +69,6 @@ export interface QueryBus {
64
69
  completeSubscriptionExceptionally(
65
70
  queryName: string,
66
71
  error: Error,
67
- filter?: (queryPayload: unknown) => boolean,
72
+ filter?: SubscriptionFilter,
68
73
  ): Promise<void>
69
74
  }
@@ -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(
@@ -2,6 +2,8 @@ import type { QueryBus } from "./query-bus.js"
2
2
  import type { QueryMessage } from "./message.js"
3
3
  import type { SubscriptionQueryResult, UpdateHandler } from "./subscription-query.js"
4
4
  import { createUpdateHandler, runAfterCommitOrImmediately } from "./subscription-query.js"
5
+ import type { SubscriptionFilter } from "./subscription-filter.js"
6
+ import { applySubscriptionFilter } from "./subscription-filter.js"
5
7
  import { runInUoW } from "./unit-of-work.js"
6
8
  import { qualifiedNameToString } from "@kronos-ts/common"
7
9
 
@@ -95,7 +97,7 @@ export function createSimpleQueryBus(): QueryBus {
95
97
 
96
98
  async emitUpdate(
97
99
  queryName: string,
98
- filter: (queryPayload: unknown) => boolean,
100
+ filter: SubscriptionFilter,
99
101
  update: unknown,
100
102
  ): Promise<void> {
101
103
  runAfterCommitOrImmediately(() => {
@@ -107,7 +109,7 @@ export function createSimpleQueryBus(): QueryBus {
107
109
 
108
110
  const handlerQueryName = qualifiedNameToString(handler.query.name)
109
111
  if (handlerQueryName !== queryName) continue
110
- if (!filter(handler.query.payload)) continue
112
+ if (!applySubscriptionFilter(filter, handler.query.payload)) continue
111
113
 
112
114
  const accepted = handler.offer(update)
113
115
  if (!accepted) {
@@ -122,13 +124,13 @@ export function createSimpleQueryBus(): QueryBus {
122
124
 
123
125
  async completeSubscription(
124
126
  queryName: string,
125
- filter?: (queryPayload: unknown) => boolean,
127
+ filter?: SubscriptionFilter,
126
128
  ): Promise<void> {
127
129
  runAfterCommitOrImmediately(() => {
128
130
  for (const [id, handler] of subscriptions) {
129
131
  const handlerQueryName = qualifiedNameToString(handler.query.name)
130
132
  if (handlerQueryName !== queryName) continue
131
- if (filter && !filter(handler.query.payload)) continue
133
+ if (filter && !applySubscriptionFilter(filter, handler.query.payload)) continue
132
134
 
133
135
  handler.complete()
134
136
  subscriptions.delete(id)
@@ -139,13 +141,13 @@ export function createSimpleQueryBus(): QueryBus {
139
141
  async completeSubscriptionExceptionally(
140
142
  queryName: string,
141
143
  error: Error,
142
- filter?: (queryPayload: unknown) => boolean,
144
+ filter?: SubscriptionFilter,
143
145
  ): Promise<void> {
144
146
  runAfterCommitOrImmediately(() => {
145
147
  for (const [id, handler] of subscriptions) {
146
148
  const handlerQueryName = qualifiedNameToString(handler.query.name)
147
149
  if (handlerQueryName !== queryName) continue
148
- if (filter && !filter(handler.query.payload)) continue
150
+ if (filter && !applySubscriptionFilter(filter, handler.query.payload)) continue
149
151
 
150
152
  handler.completeExceptionally(error)
151
153
  subscriptions.delete(id)
@@ -249,7 +249,7 @@ export function createStreamingEventProcessor(
249
249
 
250
250
  for (const reg of handlers) {
251
251
  try {
252
- await reg.handler(event.payload, event.metadata)
252
+ await reg.handler({ ...event, sequence: sequencedEvent.sequence })
253
253
  } catch (err) {
254
254
  await errorHandler.handleError(err, eventName, sequencedEvent.sequence)
255
255
  }
@@ -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
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A predicate over a query payload, used by `emitUpdate` to decide which
3
+ * subscribers should receive an update.
4
+ *
5
+ * Two forms are supported:
6
+ *
7
+ * 1. **Function** — `(payload) => boolean`. Easy to write but cannot cross a
8
+ * network boundary because functions are not serializable. Use for
9
+ * in-process / single-segment query buses.
10
+ *
11
+ * 2. **Structured `payloadEquals`** — `{ payloadEquals: Partial<P> }`. Every
12
+ * key in the partial must deep-equal the same key in the subscriber's
13
+ * query payload. Serializable, so it ships across distributed query bus
14
+ * transports (e.g. RabbitMQ broadcasts).
15
+ *
16
+ * Both forms work locally; only the structured form crosses processes when a
17
+ * distributed query bus is in use.
18
+ */
19
+ export type SubscriptionFilter<P = unknown> =
20
+ | ((payload: P) => boolean)
21
+ | { readonly payloadEquals: Partial<P> }
22
+
23
+ /**
24
+ * Helper that builds a structured filter from a partial payload.
25
+ *
26
+ * ```ts
27
+ * emitUpdate(GetCourseView, payloadEquals({ courseId: e.courseId }), view)
28
+ * ```
29
+ *
30
+ * Prefer this over a function filter when you want updates to fan out across
31
+ * a distributed query bus.
32
+ */
33
+ export function payloadEquals<P>(partial: Partial<P>): { payloadEquals: Partial<P> } {
34
+ return { payloadEquals: partial }
35
+ }
36
+
37
+ /** Evaluate a {@link SubscriptionFilter} against a payload. */
38
+ export function applySubscriptionFilter<P>(filter: SubscriptionFilter<P>, payload: P): boolean {
39
+ if (typeof filter === "function") return filter(payload)
40
+ return matchesPayloadEquals(payload, filter.payloadEquals)
41
+ }
42
+
43
+ /** Extract the structured form, if any, for serialization across a transport. */
44
+ export function extractStructuredFilter<P>(
45
+ filter: SubscriptionFilter<P> | undefined,
46
+ ): { payloadEquals: Partial<P> } | undefined {
47
+ if (!filter) return undefined
48
+ if (typeof filter === "function") return undefined
49
+ return filter
50
+ }
51
+
52
+ /** Deep equality on the keys defined in `expected`. */
53
+ export function matchesPayloadEquals<P>(payload: P, expected: Partial<P>): boolean {
54
+ if (payload === null || typeof payload !== "object") {
55
+ return Object.keys(expected).length === 0
56
+ }
57
+ for (const key of Object.keys(expected) as Array<keyof P>) {
58
+ if (!deepEqual((payload as P)[key], expected[key])) return false
59
+ }
60
+ return true
61
+ }
62
+
63
+ function deepEqual(a: unknown, b: unknown): boolean {
64
+ if (a === b) return true
65
+ if (a === null || b === null) return false
66
+ if (typeof a !== typeof b) return false
67
+ if (typeof a !== "object") return false
68
+ if (Array.isArray(a) !== Array.isArray(b)) return false
69
+ if (Array.isArray(a) && Array.isArray(b)) {
70
+ if (a.length !== b.length) return false
71
+ for (let i = 0; i < a.length; i++) {
72
+ if (!deepEqual(a[i], b[i])) return false
73
+ }
74
+ return true
75
+ }
76
+ const ao = a as Record<string, unknown>
77
+ const bo = b as Record<string, unknown>
78
+ const aKeys = Object.keys(ao)
79
+ const bKeys = Object.keys(bo)
80
+ if (aKeys.length !== bKeys.length) return false
81
+ for (const key of aKeys) {
82
+ if (!deepEqual(ao[key], bo[key])) return false
83
+ }
84
+ return true
85
+ }
@@ -268,7 +268,7 @@ export function createTrackingEventProcessor(
268
268
 
269
269
  for (const reg of handlers) {
270
270
  try {
271
- await reg.handler(event.payload, event.metadata)
271
+ await reg.handler({ ...event, sequence: sequencedEvent.sequence })
272
272
  } catch (err) {
273
273
  await errorHandler.handleError(err, eventName, sequencedEvent.sequence)
274
274
  }
@@ -8,6 +8,18 @@ import {
8
8
  } from "./processing-state.js"
9
9
  import type { UoWRunner } from "./unit-of-work.js"
10
10
 
11
+ /**
12
+ * Resource key holding a deferred-begin factory installed by
13
+ * {@link lazyTransactionalUnitOfWorkFactory}. The factory begins the tx on
14
+ * first call, registers commit/rollback hooks on the UoW, caches the
15
+ * resulting tx in {@link TRANSACTION_KEY}, and returns it. Subsequent calls
16
+ * return the cached tx without a re-begin.
17
+ *
18
+ * NOT exported from the package barrel — components reach the lazily-begun
19
+ * tx through {@link getOrBeginActiveTransaction}.
20
+ */
21
+ const LAZY_TX_FACTORY_KEY: ResourceKey<() => Promise<unknown>> = resourceKey("lazyTxFactory")
22
+
11
23
  /**
12
24
  * Manages transaction lifecycle. Users provide an implementation
13
25
  * for their specific database/ORM.
@@ -96,3 +108,75 @@ export function transactionalUnitOfWorkFactory<T>(
96
108
  })
97
109
  }
98
110
  }
111
+
112
+ /**
113
+ * Lazy variant of {@link transactionalUnitOfWorkFactory}.
114
+ *
115
+ * Unlike the eager factory, no transaction is begun on UoW entry. Instead,
116
+ * a factory is installed in the UoW that opens the tx on the first call to
117
+ * {@link getOrBeginActiveTransaction}. Pure-read UoWs that never request a
118
+ * tx pay zero begin/commit cost and never claim a connection from the pool.
119
+ *
120
+ * On first request: the tx is begun, stored in {@link TRANSACTION_KEY},
121
+ * and commit/rollback hooks are registered. Subsequent requests within
122
+ * the same UoW return the cached tx — there is exactly one tx per UoW.
123
+ *
124
+ * Components that may write to the underlying store (event stores,
125
+ * schedulers, ORM integrations) reach the tx via
126
+ * {@link getOrBeginActiveTransaction}; read-only paths use
127
+ * {@link getActiveTransaction} so they observe an existing tx but do not
128
+ * provoke one to open.
129
+ */
130
+ export function lazyTransactionalUnitOfWorkFactory<T>(
131
+ delegate: UoWRunner,
132
+ txManager: TransactionManager<T>,
133
+ ): UoWRunner {
134
+ return async (metadata, action) => {
135
+ return delegate(metadata, async () => {
136
+ let tx: T | undefined
137
+ let committed = false
138
+
139
+ const factory = async (): Promise<T> => {
140
+ if (tx !== undefined) return tx
141
+ tx = await txManager.begin()
142
+ setResource(TRANSACTION_KEY, tx)
143
+ on(Phase.COMMIT, async () => {
144
+ if (tx === undefined) return
145
+ await txManager.commit(tx)
146
+ committed = true
147
+ })
148
+ onError(async () => {
149
+ if (tx === undefined || committed) return
150
+ await txManager.rollback(tx)
151
+ })
152
+ return tx
153
+ }
154
+
155
+ setResource(LAZY_TX_FACTORY_KEY, factory as () => Promise<unknown>)
156
+ return action()
157
+ })
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Return the active UoW transaction, opening it if a lazy factory is
163
+ * installed and no tx has been begun yet. Returns the cached tx on
164
+ * subsequent calls within the same UoW.
165
+ *
166
+ * Returns `undefined` when no UoW is active OR when an active UoW has
167
+ * neither an existing tx nor a lazy factory installed (e.g., the app
168
+ * doesn't compose a TransactionManager). Callers that need a tx must
169
+ * decide what to do with `undefined` — typically fall back to opening
170
+ * an ad-hoc tx on their own driver.
171
+ */
172
+ export async function getOrBeginActiveTransaction<T = unknown>(): Promise<T | undefined> {
173
+ const state = processingStateStorage.getStore()
174
+ if (!state) return undefined
175
+ const existing = state.resources.get(TRANSACTION_KEY.symbol) as T | undefined
176
+ if (existing !== undefined) return existing
177
+ const factory = state.resources.get(LAZY_TX_FACTORY_KEY.symbol) as
178
+ | (() => Promise<T>)
179
+ | undefined
180
+ if (factory === undefined) return undefined
181
+ return await factory()
182
+ }