@kronos-ts/messaging 0.1.0 → 0.2.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 (40) hide show
  1. package/dist/emit-update.d.ts +2 -1
  2. package/dist/emit-update.d.ts.map +1 -1
  3. package/dist/emit-update.js.map +1 -1
  4. package/dist/event-scheduler.d.ts +95 -0
  5. package/dist/event-scheduler.d.ts.map +1 -0
  6. package/dist/event-scheduler.js +47 -0
  7. package/dist/event-scheduler.js.map +1 -0
  8. package/dist/in-memory-event-scheduler.d.ts +45 -0
  9. package/dist/in-memory-event-scheduler.d.ts.map +1 -0
  10. package/dist/in-memory-event-scheduler.js +112 -0
  11. package/dist/in-memory-event-scheduler.js.map +1 -0
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/intercepting-query-bus.d.ts.map +1 -1
  17. package/dist/intercepting-query-bus.js.map +1 -1
  18. package/dist/query-bus.d.ts +8 -3
  19. package/dist/query-bus.d.ts.map +1 -1
  20. package/dist/simple-query-bus.d.ts.map +1 -1
  21. package/dist/simple-query-bus.js +4 -3
  22. package/dist/simple-query-bus.js.map +1 -1
  23. package/dist/subscription-filter.d.ts +43 -0
  24. package/dist/subscription-filter.d.ts.map +1 -0
  25. package/dist/subscription-filter.js +71 -0
  26. package/dist/subscription-filter.js.map +1 -0
  27. package/dist/transaction.d.ts +31 -0
  28. package/dist/transaction.d.ts.map +1 -1
  29. package/dist/transaction.js +80 -0
  30. package/dist/transaction.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/emit-update.ts +3 -2
  33. package/src/event-scheduler.ts +96 -0
  34. package/src/in-memory-event-scheduler.ts +150 -0
  35. package/src/index.ts +22 -0
  36. package/src/intercepting-query-bus.ts +4 -3
  37. package/src/query-bus.ts +8 -3
  38. package/src/simple-query-bus.ts +8 -6
  39. package/src/subscription-filter.ts +85 -0
  40. package/src/transaction.ts +84 -0
@@ -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)
@@ -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
+ }
@@ -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
+ }