@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
@@ -122,7 +122,7 @@ export function createCommandInvocation(
122
122
  )
123
123
 
124
124
  const finalCriteria = handler.appendCondition
125
- ? handler.appendCondition(message.payload, combinedCriteria)
125
+ ? handler.appendCondition(message, combinedCriteria)
126
126
  : combinedCriteria
127
127
 
128
128
  appendCondition = {
@@ -135,7 +135,7 @@ export function createCommandInvocation(
135
135
  })
136
136
  }
137
137
 
138
- return handler.handler(message.payload, message.metadata)
138
+ return handler.handler(message)
139
139
  }
140
140
  }
141
141
 
@@ -74,7 +74,7 @@ export function createDeadLetteringDelivery(options: DeadLetteringOptions) {
74
74
  // Try to deliver to all handlers
75
75
  for (const reg of handlers) {
76
76
  try {
77
- await reg.handler(event.payload, event.metadata)
77
+ await reg.handler({ ...event, sequence: sequencedEvent.sequence })
78
78
  } catch (err) {
79
79
  const error = err instanceof Error ? err : new Error(String(err))
80
80
  const letter = createDeadLetter(event, error, seqId, {
@@ -3,12 +3,13 @@ import { resourceKey, qualifiedNameToString, type ResourceKey } from "@kronos-ts
3
3
  import { requireInvocationPhase } from "./processing-state.js"
4
4
  import type { QueryBus } from "./query-bus.js"
5
5
  import type { QueryDescriptor } from "./descriptor.js"
6
+ import type { SubscriptionFilter } from "./subscription-filter.js"
6
7
 
7
8
  /** Emit a subscription-query update from within the current processing context. */
8
9
  export interface EmitUpdateFunction {
9
10
  <Q extends z.ZodType>(
10
11
  query: QueryDescriptor<Q>,
11
- filter: (query: z.infer<Q>) => boolean,
12
+ filter: SubscriptionFilter<z.infer<Q>>,
12
13
  update: unknown,
13
14
  ): void
14
15
  }
@@ -31,5 +32,5 @@ export const emitUpdate: EmitUpdateFunction = (queryDescriptor, filter, update)
31
32
  const bus = state.resources.get(QUERY_BUS_KEY.symbol) as QueryBus | undefined
32
33
  if (!bus) throw new Error("No query bus configured")
33
34
  const queryName = qualifiedNameToString(queryDescriptor.name)
34
- bus.emitUpdate(queryName, filter as (q: unknown) => boolean, update)
35
+ bus.emitUpdate(queryName, filter as SubscriptionFilter, update)
35
36
  }
@@ -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)
@@ -0,0 +1,96 @@
1
+ /**
2
+ * EventScheduler — schedule an event to be appended to the event store at
3
+ * a future time, with the option to cancel before the fire-time.
4
+ *
5
+ * # Semantics
6
+ *
7
+ * - `schedule(event, at)` MUST be called from inside a UnitOfWork (i.e.,
8
+ * from a command/event/query handler, or any code that opened a UoW
9
+ * via `runInNewUoW`). The scheduled record participates in the active
10
+ * UoW transaction: if the UoW rolls back, the schedule is not persisted;
11
+ * if the UoW commits, the schedule is durably stored.
12
+ *
13
+ * - Once the schedule is committed, the implementation guarantees that the
14
+ * event WILL be appended to the event store at or after `at`, unless
15
+ * {@link EventScheduler.cancel} is called and succeeds before the
16
+ * fire-time. "At or after" because workers poll on an interval — fire
17
+ * times are not real-time deadlines.
18
+ *
19
+ * - `cancel(token)` returns a {@link CancelResult} discriminated union so
20
+ * callers can branch on three distinct outcomes: the schedule was
21
+ * cancelled before firing, the event had already been appended (too
22
+ * late), or no such schedule exists (already-cancelled, never-existed,
23
+ * or token from a different deployment).
24
+ *
25
+ * - Cancel is also UoW-aware: when called inside a UoW, it participates
26
+ * in the active tx so a handler that cancels and then throws does NOT
27
+ * leave the schedule cancelled. When called outside any UoW (rare —
28
+ * typically an ops/admin path), it commits standalone.
29
+ *
30
+ * # Calling from handlers
31
+ *
32
+ * Implementations resolve themselves from the active UoW's resources
33
+ * (similar to {@link send} and {@link emitUpdate}), so handler code uses
34
+ * the scheduler the framework configured for it. The interface itself
35
+ * is transport-agnostic — postgres and in-memory implementations live
36
+ * in their respective packages.
37
+ *
38
+ * # NOT what this is
39
+ *
40
+ * - This is NOT a command scheduler. AF5 schedules events, not commands;
41
+ * if you want a command to run later, schedule an event and run an
42
+ * automation processor that turns the event into a command on receipt.
43
+ * - This is NOT a cron / recurring scheduler. Each `schedule()` produces
44
+ * a single one-shot fire.
45
+ */
46
+
47
+ import type { EventMessage } from "./message.js"
48
+
49
+ /**
50
+ * Opaque handle returned by {@link EventScheduler.schedule}. Pass back to
51
+ * {@link EventScheduler.cancel} to attempt cancellation. Tokens are
52
+ * implementation-specific (postgres uses the row PK; in-memory uses a
53
+ * UUID) but always carry a stable `id`.
54
+ */
55
+ export interface ScheduleToken {
56
+ readonly id: string
57
+ }
58
+
59
+ /**
60
+ * Outcome of {@link EventScheduler.cancel}.
61
+ *
62
+ * - `cancelled` — the schedule existed in `pending` state and was
63
+ * successfully marked `cancelled`. Event will NOT
64
+ * be appended.
65
+ * - `already-appended` — a worker already fired this schedule; the event
66
+ * is in the event store. Caller decides whether
67
+ * compensation is needed.
68
+ * - `not-found` — no row matches the token. Could mean: already
69
+ * cancelled, never existed, or wrong store. Caller
70
+ * usually treats this as a no-op.
71
+ */
72
+ export type CancelResult =
73
+ | { readonly kind: "cancelled" }
74
+ | { readonly kind: "already-appended" }
75
+ | { readonly kind: "not-found" }
76
+
77
+ export interface EventScheduler {
78
+ /**
79
+ * Schedule {@link event} for append at {@link at}. Must be called inside
80
+ * a UoW; throws otherwise. The schedule participates in the active UoW
81
+ * tx and is only durable once the UoW commits.
82
+ *
83
+ * `at` is the wall-clock fire-time. Past dates are valid — they cause
84
+ * the worker to fire the schedule on its next poll.
85
+ */
86
+ schedule(event: EventMessage, at: Date): Promise<ScheduleToken>
87
+
88
+ /**
89
+ * Attempt to cancel a pending schedule. See {@link CancelResult} for
90
+ * the three possible outcomes.
91
+ *
92
+ * Safe to call from inside a UoW (joins the active tx) or outside
93
+ * (commits standalone).
94
+ */
95
+ cancel(token: ScheduleToken): Promise<CancelResult>
96
+ }
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
  }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * In-memory {@link EventScheduler} — intended for tests only.
3
+ *
4
+ * Backed by a `Map<scheduleId, record>` and `setTimeout`. Publishes fired
5
+ * events through a supplied {@link EventSink} (typically the in-memory
6
+ * event bus or a test spy).
7
+ *
8
+ * # UoW semantics (best-effort, test-grade)
9
+ *
10
+ * - `schedule()` must be called inside a UoW (INVOCATION phase). The
11
+ * record is staged immediately so cancel() inside the SAME UoW sees it,
12
+ * but the `setTimeout` arming is deferred to AFTER_COMMIT — so if the
13
+ * UoW rolls back the schedule never fires. `onError` cleans the staged
14
+ * record so callers see `not-found` on the rolled-back token.
15
+ *
16
+ * - `cancel()` may be called inside or outside a UoW. State change is
17
+ * applied immediately for caller-visibility; this means a UoW that
18
+ * cancels and then rolls back does NOT restore the schedule. This
19
+ * differs from the postgres implementation (which is true
20
+ * transactional) and is acceptable for the in-memory's test-only
21
+ * remit. Document this when writing tests that depend on cancel
22
+ * rollback semantics — use the postgres scheduler for that.
23
+ *
24
+ * # NOT production
25
+ *
26
+ * No persistence, no recovery on restart, no at-least-once. A test-only
27
+ * spy with a real-enough surface to exercise framework wiring.
28
+ */
29
+
30
+ import type { EventMessage } from "./message.js"
31
+ import type { EventSink } from "./event-sink.js"
32
+ import type { EventScheduler, ScheduleToken, CancelResult } from "./event-scheduler.js"
33
+ import {
34
+ requireInvocationPhase,
35
+ onAfterCommit,
36
+ onError,
37
+ processingStateStorage,
38
+ } from "./processing-state.js"
39
+ import { generateIdentifier } from "@kronos-ts/common"
40
+
41
+ type RecordStatus = "pending" | "appended" | "cancelled"
42
+
43
+ interface ScheduleRecord {
44
+ status: RecordStatus
45
+ event: EventMessage
46
+ fireAt: number
47
+ timer?: ReturnType<typeof setTimeout>
48
+ }
49
+
50
+ export interface InMemoryEventSchedulerOptions {
51
+ readonly eventSink: EventSink
52
+ /** Override `Date.now` for deterministic tests. Defaults to `Date.now`. */
53
+ readonly now?: () => number
54
+ }
55
+
56
+ export interface InMemoryEventScheduler extends EventScheduler {
57
+ /**
58
+ * Cancel any armed timers and drop all internal state. Tests call this
59
+ * in `afterEach` to ensure schedulers from one test do not fire into
60
+ * another. Not part of the public {@link EventScheduler} contract.
61
+ */
62
+ stop(): Promise<void>
63
+ }
64
+
65
+ export function createInMemoryEventScheduler(
66
+ options: InMemoryEventSchedulerOptions,
67
+ ): InMemoryEventScheduler {
68
+ const { eventSink } = options
69
+ const now = options.now ?? Date.now
70
+ const records = new Map<string, ScheduleRecord>()
71
+
72
+ function armTimer(id: string, record: ScheduleRecord): void {
73
+ const delay = Math.max(0, record.fireAt - now())
74
+ record.timer = setTimeout(() => {
75
+ const rec = records.get(id)
76
+ if (!rec || rec.status !== "pending") return
77
+ rec.status = "appended"
78
+ rec.timer = undefined
79
+ eventSink.publish([rec.event]).catch((err) => {
80
+ // Test-only: surface but do not crash the process. Real
81
+ // implementations need an at-least-once retry; not modelled here.
82
+ console.warn("inMemoryEventScheduler: publish failed:", err)
83
+ })
84
+ }, delay)
85
+ }
86
+
87
+ return {
88
+ async schedule(event: EventMessage, at: Date): Promise<ScheduleToken> {
89
+ requireInvocationPhase()
90
+
91
+ const id = generateIdentifier()
92
+ const record: ScheduleRecord = {
93
+ status: "pending",
94
+ event,
95
+ fireAt: at.getTime(),
96
+ }
97
+ records.set(id, record)
98
+
99
+ onAfterCommit(() => {
100
+ const rec = records.get(id)
101
+ if (!rec || rec.status !== "pending") return
102
+ armTimer(id, rec)
103
+ })
104
+
105
+ onError(() => {
106
+ // Roll back the staged record so post-rollback cancel() sees
107
+ // `not-found` rather than `cancelled`.
108
+ const rec = records.get(id)
109
+ if (rec && rec.status === "pending") records.delete(id)
110
+ })
111
+
112
+ return { id }
113
+ },
114
+
115
+ async cancel(token: ScheduleToken): Promise<CancelResult> {
116
+ const rec = records.get(token.id)
117
+ if (!rec) return { kind: "not-found" }
118
+ if (rec.status === "appended") return { kind: "already-appended" }
119
+ if (rec.status === "cancelled") return { kind: "not-found" }
120
+
121
+ rec.status = "cancelled"
122
+ if (rec.timer !== undefined) {
123
+ clearTimeout(rec.timer)
124
+ rec.timer = undefined
125
+ }
126
+
127
+ // Best-effort UoW participation: if we're inside a UoW that later
128
+ // errors, revert the cancel so the schedule's pending state
129
+ // re-materialises. The original timer (if it was armed) has already
130
+ // been cleared — the AFTER_COMMIT re-arm cycle is not re-driven
131
+ // here, which means a cancel + rollback inside a post-commit window
132
+ // would not re-fire. Acceptable for the in-memory's test-only remit.
133
+ if (processingStateStorage.getStore() !== undefined) {
134
+ onError(() => {
135
+ const r = records.get(token.id)
136
+ if (r && r.status === "cancelled") r.status = "pending"
137
+ })
138
+ }
139
+
140
+ return { kind: "cancelled" }
141
+ },
142
+
143
+ async stop(): Promise<void> {
144
+ for (const rec of records.values()) {
145
+ if (rec.timer !== undefined) clearTimeout(rec.timer)
146
+ }
147
+ records.clear()
148
+ },
149
+ }
150
+ }
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
 
@@ -145,6 +146,14 @@ export {
145
146
  runAfterCommitOrImmediately,
146
147
  } from "./subscription-query.js"
147
148
 
149
+ export {
150
+ type SubscriptionFilter,
151
+ payloadEquals,
152
+ applySubscriptionFilter,
153
+ extractStructuredFilter,
154
+ matchesPayloadEquals,
155
+ } from "./subscription-filter.js"
156
+
148
157
  // Event sink (publish-only)
149
158
  export { type EventSink } from "./event-sink.js"
150
159
 
@@ -237,7 +246,9 @@ export {
237
246
  type TransactionManager,
238
247
  noTransactionManager,
239
248
  getActiveTransaction,
249
+ getOrBeginActiveTransaction,
240
250
  transactionalUnitOfWorkFactory,
251
+ lazyTransactionalUnitOfWorkFactory,
241
252
  TRANSACTION_KEY,
242
253
  } from "./transaction.js"
243
254
 
@@ -252,6 +263,18 @@ export {
252
263
  export { send, COMMAND_BUS_KEY } from "./send.js"
253
264
  export { emitUpdate, QUERY_BUS_KEY } from "./emit-update.js"
254
265
 
266
+ // Event scheduling
267
+ export {
268
+ type EventScheduler,
269
+ type ScheduleToken,
270
+ type CancelResult,
271
+ } from "./event-scheduler.js"
272
+ export {
273
+ type InMemoryEventScheduler,
274
+ type InMemoryEventSchedulerOptions,
275
+ createInMemoryEventScheduler,
276
+ } from "./in-memory-event-scheduler.js"
277
+
255
278
  // Modules — Plan 08-03a (D-82): function-style helpers replace Module-shape factories
256
279
  export {
257
280
  registerCommandHandlersNatively,
@@ -353,4 +376,3 @@ export {
353
376
 
354
377
  // Namespace factory
355
378
  export { withNamespace } from "./with-namespace.js"
356
-
@@ -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)
@@ -1,6 +1,7 @@
1
1
  import type { QueryBus } from "./query-bus.js"
2
2
  import type { QueryMessage } from "./message.js"
3
3
  import type { SubscriptionQueryResult } from "./subscription-query.js"
4
+ import type { SubscriptionFilter } from "./subscription-filter.js"
4
5
  import type { DispatchInterceptor, HandlerInterceptor } from "./interceptor.js"
5
6
 
6
7
  /**
@@ -16,10 +17,10 @@ export function createInterceptingQueryBus(
16
17
  /** Register a dispatch interceptor. Returns an unsubscribe function. */
17
18
  registerDispatchInterceptor(interceptor: DispatchInterceptor<QueryMessage>): () => void
18
19
  /** Register a handler interceptor. Returns an unsubscribe function. */
19
- registerHandlerInterceptor(interceptor: HandlerInterceptor): () => void
20
+ registerHandlerInterceptor(interceptor: HandlerInterceptor<QueryMessage>): () => void
20
21
  } {
21
22
  const dispatchInterceptors: Array<DispatchInterceptor<QueryMessage>> = []
22
- const handlerInterceptors: Array<HandlerInterceptor> = []
23
+ const handlerInterceptors: Array<HandlerInterceptor<QueryMessage>> = []
23
24
 
24
25
  return {
25
26
  async query(message: QueryMessage): Promise<unknown> {
@@ -35,20 +36,21 @@ export function createInterceptingQueryBus(
35
36
  queryName: string,
36
37
  handler: (message: QueryMessage) => Promise<unknown>,
37
38
  ) {
38
- // Wrap the handler with handler interceptors
39
39
  const wrappedHandler = (message: QueryMessage) => {
40
40
  if (handlerInterceptors.length === 0) {
41
41
  return handler(message)
42
42
  }
43
43
 
44
- let chain = () => handler(message)
44
+ let chain = (currentMessage: QueryMessage) => handler(currentMessage)
45
45
  for (let i = handlerInterceptors.length - 1; i >= 0; i--) {
46
46
  const interceptor = handlerInterceptors[i]!
47
47
  const next = chain
48
- chain = () => interceptor(message, next)
48
+ chain = (currentMessage: QueryMessage) =>
49
+ interceptor(currentMessage, (replacementMessage) =>
50
+ next(replacementMessage ?? currentMessage))
49
51
  }
50
52
 
51
- return chain()
53
+ return chain(message)
52
54
  }
53
55
 
54
56
  delegate.subscribe(queryName, wrappedHandler)
@@ -64,7 +66,7 @@ export function createInterceptingQueryBus(
64
66
 
65
67
  emitUpdate(
66
68
  queryName: string,
67
- filter: (queryPayload: unknown) => boolean,
69
+ filter: SubscriptionFilter,
68
70
  update: unknown,
69
71
  ): Promise<void> {
70
72
  return delegate.emitUpdate(queryName, filter, update)
@@ -72,7 +74,7 @@ export function createInterceptingQueryBus(
72
74
 
73
75
  completeSubscription(
74
76
  queryName: string,
75
- filter?: (queryPayload: unknown) => boolean,
77
+ filter?: SubscriptionFilter,
76
78
  ): Promise<void> {
77
79
  return delegate.completeSubscription(queryName, filter)
78
80
  },
@@ -80,7 +82,7 @@ export function createInterceptingQueryBus(
80
82
  completeSubscriptionExceptionally(
81
83
  queryName: string,
82
84
  error: Error,
83
- filter?: (queryPayload: unknown) => boolean,
85
+ filter?: SubscriptionFilter,
84
86
  ): Promise<void> {
85
87
  return delegate.completeSubscriptionExceptionally(queryName, error, filter)
86
88
  },