bubus 1.7.3 → 2.2.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 (68) hide show
  1. package/README.md +775 -266
  2. package/dist/esm/async_context.js +39 -0
  3. package/dist/esm/async_context.js.map +7 -0
  4. package/dist/esm/base_event.js +825 -0
  5. package/dist/esm/base_event.js.map +7 -0
  6. package/dist/esm/bridge_jsonl.js +150 -0
  7. package/dist/esm/bridge_jsonl.js.map +7 -0
  8. package/dist/esm/bridge_nats.js +88 -0
  9. package/dist/esm/bridge_nats.js.map +7 -0
  10. package/dist/esm/bridge_postgres.js +231 -0
  11. package/dist/esm/bridge_postgres.js.map +7 -0
  12. package/dist/esm/bridge_redis.js +155 -0
  13. package/dist/esm/bridge_redis.js.map +7 -0
  14. package/dist/esm/bridge_sqlite.js +235 -0
  15. package/dist/esm/bridge_sqlite.js.map +7 -0
  16. package/dist/esm/bridges.js +306 -0
  17. package/dist/esm/bridges.js.map +7 -0
  18. package/dist/esm/event_bus.js +1046 -0
  19. package/dist/esm/event_bus.js.map +7 -0
  20. package/dist/esm/event_handler.js +279 -0
  21. package/dist/esm/event_handler.js.map +7 -0
  22. package/dist/esm/event_history.js +172 -0
  23. package/dist/esm/event_history.js.map +7 -0
  24. package/dist/esm/event_result.js +426 -0
  25. package/dist/esm/event_result.js.map +7 -0
  26. package/dist/esm/events_suck.js +39 -0
  27. package/dist/esm/events_suck.js.map +7 -0
  28. package/dist/esm/helpers.js +64 -0
  29. package/dist/esm/helpers.js.map +7 -0
  30. package/dist/esm/index.js +37 -16206
  31. package/dist/esm/index.js.map +4 -4
  32. package/dist/esm/lock_manager.js +323 -0
  33. package/dist/esm/lock_manager.js.map +7 -0
  34. package/dist/esm/logging.js +196 -0
  35. package/dist/esm/logging.js.map +7 -0
  36. package/dist/esm/middlewares.js +1 -0
  37. package/dist/esm/middlewares.js.map +7 -0
  38. package/dist/esm/optional_deps.js +34 -0
  39. package/dist/esm/optional_deps.js.map +7 -0
  40. package/dist/esm/retry.js +237 -0
  41. package/dist/esm/retry.js.map +7 -0
  42. package/dist/esm/timing.js +56 -0
  43. package/dist/esm/timing.js.map +7 -0
  44. package/dist/esm/types.js +84 -0
  45. package/dist/esm/types.js.map +7 -0
  46. package/dist/types/async_context.d.ts +4 -2
  47. package/dist/types/base_event.d.ts +124 -76
  48. package/dist/types/bridge_jsonl.d.ts +26 -0
  49. package/dist/types/bridge_nats.d.ts +20 -0
  50. package/dist/types/bridge_postgres.d.ts +31 -0
  51. package/dist/types/bridge_redis.d.ts +34 -0
  52. package/dist/types/bridge_sqlite.d.ts +30 -0
  53. package/dist/types/bridges.d.ts +49 -0
  54. package/dist/types/event_bus.d.ts +99 -68
  55. package/dist/types/event_handler.d.ts +73 -19
  56. package/dist/types/event_history.d.ts +45 -0
  57. package/dist/types/event_result.d.ts +60 -38
  58. package/dist/types/events_suck.d.ts +40 -0
  59. package/dist/types/helpers.d.ts +1 -0
  60. package/dist/types/index.d.ts +13 -2
  61. package/dist/types/lock_manager.d.ts +33 -31
  62. package/dist/types/logging.d.ts +5 -1
  63. package/dist/types/middlewares.d.ts +13 -0
  64. package/dist/types/optional_deps.d.ts +3 -0
  65. package/dist/types/retry.d.ts +52 -0
  66. package/dist/types/timing.d.ts +3 -0
  67. package/dist/types/types.d.ts +18 -7
  68. package/package.json +32 -11
package/README.md CHANGED
@@ -1,362 +1,871 @@
1
- # bubus-ts: Python vs JS Differences (and the tricky parts)
1
+ # `bubus`: 📢 Production-ready multi-language event bus
2
2
 
3
- This README only covers the differences between the Python implementation and this TypeScript port, plus the
4
- gotchas we uncovered while matching behavior. It intentionally does **not** re-document the full TS API surface.
3
+ <img width="200" alt="image" src="https://github.com/user-attachments/assets/b3525c24-51ba-496c-b327-ccdfe46a7362" align="right" />
5
4
 
6
- ## Key Differences vs Python
5
+ [![DeepWiki: Python](https://img.shields.io/badge/DeepWiki-bbus%2FPython-yellow.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/pirate/bbus) [![PyPI - Version](https://img.shields.io/pypi/v/bubus)](https://pypi.org/project/bubus/) [![GitHub License](https://img.shields.io/github/license/pirate/bbus)](https://github.com/pirate/bbus) ![GitHub last commit](https://img.shields.io/github/last-commit/pirate/bbus)
7
6
 
8
- ### 1) Awaiting events: `event.done()` instead of `await event`
7
+ [![DeepWiki: TS](https://img.shields.io/badge/DeepWiki-bbus%2FTypescript-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/pirate/bbus/3-typescript-implementation) [![NPM Version](https://img.shields.io/npm/v/bubus)](https://www.npmjs.com/package/bubus)
9
8
 
10
- - Python: `await event` waits for handlers and can jump the queue when awaited inside a handler.
11
- - TS: use `await event.done()` for the same behavior.
12
- - Outside a handler, `done()` just waits for completion (it does not jump the queue).
13
- - Inside a handler, `done()` triggers immediate processing (queue jump) on **all buses** where the event is queued.
9
+ Bubus is an in-memory event bus library for async Python and TS (node/bun/deno/browser).
14
10
 
15
- ### 2) Cross-bus queue jump (forwarding)
11
+ It's designed for quickly building resilient, predictable, complex event-driven apps.
16
12
 
17
- - Python uses a global re-entrant lock to let awaited events process immediately on every bus where they appear.
18
- - TS optionally uses `AsyncLocalStorage` on Node.js (auto-detected) to capture dispatch context, but falls back gracefully in browsers.
19
- - `EventBus._all_instances` + the `LockManager` pause mechanism pauses each runloop and processes the same event immediately across buses.
13
+ It "just works" with an intuitive, but powerful event JSON format + emit API that's consistent across both languages and scales consistently from one event to millions (~0.2ms/event):
20
14
 
21
- ### 3) `event.bus` is a BusScopedEvent view
15
+ ```python
16
+ bus.on(SomeEvent, some_function)
17
+ bus.emit(SomeEvent({some_data: 132}))
18
+ ```
22
19
 
23
- - In Python, `event.event_bus` is dynamic (contextvars).
24
- - In TS, `event.bus` is provided by a **BusScopedEvent** (a Proxy over the original event).
25
- - That proxy injects a bus-bound `emit/dispatch` to ensure correct parent/child tracking.
20
+ It's async native, has proper automatic nested event tracking, and powerful concurrency control options. The API is inspired by `EventEmitter` or [`emittery`](https://github.com/sindresorhus/emittery) in JS, but it takes it a step further:
26
21
 
27
- ### 4) Monotonic timestamps
22
+ - nice Zod / Pydantic schemas for events that can be exchanged between both languages
23
+ - automatic UUIDv7s and monotonic nanosecond timestamps for ordering events globally
24
+ - built in locking options to force strict global FIFO processing or fully parallel processing
28
25
 
29
- - JS `Date.now()` is not strictly monotonic at millisecond granularity.
30
- - To keep FIFO tests stable, we generate strictly increasing timestamps via `BaseEvent.nextTimestamp()` (returns `{ date, isostring, ts }`).
26
+ ---
31
27
 
32
- ### 5) No middleware, no WAL, no SQLite mirrors
28
+ ♾️ It's inspired by the simplicity of async and events in `JS` but with baked-in features that allow to eliminate most of the tedious repetitive complexity in event-driven codebases:
33
29
 
34
- - Those Python features were intentionally dropped for the JS version.
30
+ - correct timeout enforcement across multiple levels of events, if a parent times out it correctly aborts all child event processing
31
+ - ability to strongly type hint and enforce the return type of event handlers at compile-time
32
+ - ability to queue events on the bus, or inline await them for immediate execution like a normal function call
33
+ - handles ~5,000 events/sec/core in both languages, with ~2kb/event RAM consumed per event during active processing
35
34
 
36
- ### 6) Default timeouts come from the EventBus
35
+ <br/>
37
36
 
38
- - `BaseEvent.event_timeout` defaults to `null`.
39
- - When dispatched, `EventBus` applies its default `event_timeout` (60s unless configured).
40
- - You can set `{ event_timeout: null }` on the bus to disable timeouts entirely.
41
- - Slow handler warnings fire after `event_handler_slow_timeout` (default: `30s`). Slow event warnings fire after `event_slow_timeout` (default: `300s`).
37
+ ## 🔢 Quickstart
42
38
 
43
- ## EventBus Options
39
+ ```bash
40
+ npm install bubus
41
+ ```
44
42
 
45
- All options are passed to `new EventBus(name, options)`.
43
+ ```ts
44
+ import { BaseEvent, EventBus } from 'bubus'
45
+ import { z } from 'zod'
46
46
 
47
- - `max_history_size?: number | null` (default: `100`)
48
- - Max number of events kept in history. Set to `null` for unlimited history.
49
- - `event_concurrency?: "global-serial" | "bus-serial" | "parallel" | "auto"` (default: `"bus-serial"`)
50
- - Controls how many **events** can be processed at a time.
51
- - `"global-serial"` enforces FIFO across all buses.
52
- - `"bus-serial"` enforces FIFO per bus, allows cross-bus overlap.
53
- - `"parallel"` allows events to process concurrently.
54
- - `"auto"` uses the bus default (mostly useful for overrides).
55
- - `event_handler_concurrency?: "global-serial" | "bus-serial" | "parallel" | "auto"` (default: `"bus-serial"`)
56
- - Controls how many **handlers** run at once for each event.
57
- - Same semantics as `event_concurrency`, but applied to handler execution.
58
- - `event_timeout?: number | null` (default: `60`)
59
- - Default handler timeout in seconds, applied when `event.event_timeout` is `null`.
60
- - Set to `null` to disable timeouts globally for the bus.
61
- - `event_handler_slow_timeout?: number | null` (default: `30`)
62
- - Warn after this many seconds for slow handlers.
63
- - Only warns when the handler's timeout is `null` or greater than this value.
64
- - Set to `null` to disable slow handler warnings.
65
- - `event_slow_timeout?: number | null` (default: `300`)
66
- - Warn after this many seconds for slow event processing.
67
- - Set to `null` to disable slow event warnings.
47
+ const CreateUserEvent = BaseEvent.extend('CreateUserEvent', {
48
+ email: z.string(),
49
+ event_result_type: z.object({ user_id: z.string() }),
50
+ })
51
+
52
+ const bus = new EventBus('MyAuthEventBus')
53
+
54
+ bus.on(CreateUserEvent, async (event) => {
55
+ const user = await yourCreateUserLogic(event.email)
56
+ return { user_id: user.id }
57
+ })
58
+
59
+ const event = bus.emit(CreateUserEvent({ email: 'someuser@example.com' }))
60
+ await event.done()
61
+ console.log(event.event_result) // { user_id: 'some-user-uuid' }
62
+ ```
63
+
64
+ <br/>
65
+
66
+ ---
67
+
68
+ <br/>
69
+
70
+ ## ✨ Features
71
+
72
+ <details>
73
+ <summary><strong>See the core TypeScript features and how they map to Python.</strong></summary>
74
+
75
+ The features offered in TS are broadly similar to the ones offered in the python library.
76
+
77
+ - Typed events with Zod schemas (cross-compatible with Pydantic events from python library)
78
+ - FIFO event queueing with configurable concurrency
79
+ - Nested event support with automatic parent/child tracking
80
+ - Cross-bus forwarding with loop prevention
81
+ - Handler result tracking + validation + timeout enforcement
82
+ - History retention controls (`max_history_size`) for memory bounds
83
+ - Optional `@retry` decorator for easy management of per-handler retries, timeouts, and semaphore-limited execution
84
+
85
+ See the [Python README](../README.md) for more details.
86
+
87
+ </details>
88
+
89
+ <br/>
90
+
91
+ ---
92
+
93
+ <br/>
68
94
 
69
- ## Concurrency Overrides and Precedence
95
+ ## 📚 API Documentation
70
96
 
71
- You can override concurrency per event and per handler:
97
+ <details>
98
+ <summary><strong>Review bus construction, defaults, and core lifecycle methods.</strong></summary>
99
+
100
+ The main bus class that registers handlers, schedules events, and tracks results.
101
+
102
+ Constructor:
72
103
 
73
104
  ```ts
74
- const FastEvent = BaseEvent.extend('FastEvent', {
75
- payload: z.string(),
105
+ new EventBus(name?: string, options?: {
106
+ id?: string
107
+ max_history_size?: number | null
108
+ event_concurrency?: 'global-serial' | 'bus-serial' | 'parallel' | null
109
+ event_timeout?: number | null
110
+ event_slow_timeout?: number | null
111
+ event_handler_concurrency?: 'serial' | 'parallel' | null
112
+ event_handler_completion?: 'all' | 'first'
113
+ event_handler_slow_timeout?: number | null
114
+ event_handler_detect_file_paths?: boolean
76
115
  })
116
+ ```
77
117
 
78
- // Per-event override (highest precedence)
79
- const event = FastEvent({
80
- payload: 'x',
81
- event_concurrency: 'parallel',
82
- event_handler_concurrency: 'parallel',
118
+ #### Constructor options
119
+
120
+ | Option | Type | Default | Purpose |
121
+ | --------------------------------- | ------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
122
+ | `id` | `string` | `uuidv7()` | Override bus UUID (mostly for serialization/tests). |
123
+ | `max_history_size` | `number \| null` | `100` | Max events kept in `event_history`; `null` = unbounded; `0` = keep only in-flight events and drop completed events immediately. |
124
+ | `max_history_drop` | `boolean` | `false` | If `true`, when history is full drop oldest history entries (including uncompleted if needed). If `false`, reject new emits when history reaches `max_history_size`. |
125
+ | `event_concurrency` | `'global-serial' \| 'bus-serial' \| 'parallel' \| null` | `'bus-serial'` | Event-level scheduling policy. |
126
+ | `event_handler_concurrency` | `'serial' \| 'parallel' \| null` | `'serial'` | Per-event handler scheduling policy. |
127
+ | `event_handler_completion` | `'all' \| 'first'` | `'all'` | Event completion mode if event does not override it. |
128
+ | `event_timeout` | `number \| null` | `60` | Default per-handler timeout budget in seconds (unless overridden). |
129
+ | `event_handler_slow_timeout` | `number \| null` | `30` | Slow handler warning threshold (seconds). |
130
+ | `event_slow_timeout` | `number \| null` | `300` | Slow event warning threshold (seconds). |
131
+ | `event_handler_detect_file_paths` | `boolean` | `true` | Capture source file:line for handlers (slower, better logs). |
132
+
133
+ #### Runtime state properties
134
+
135
+ - `id: string`
136
+ - `name: string`
137
+ - `label: string` (`${name}#${id.slice(-4)}`)
138
+ - `handlers: Map<string, EventHandler>`
139
+ - `handlers_by_key: Map<string, string[]>`
140
+ - `event_history: Map<string, BaseEvent>`
141
+ - `pending_event_queue: BaseEvent[]`
142
+ - `in_flight_event_ids: Set<string>`
143
+ - `locks: LockManager`
144
+
145
+ #### `on()`
146
+
147
+ ```ts
148
+ on<T extends BaseEvent>(
149
+ event_pattern: string | '*' | EventClass<T>,
150
+ handler: EventHandlerCallable<T>,
151
+ options?: Partial<EventHandler>
152
+ ): EventHandler
153
+ ```
154
+
155
+ Use during startup/composition to register handlers.
156
+
157
+ Advanced `options` fields, these can be used to override defaults per-handler if needed:
158
+
159
+ - `handler_timeout?: number | null` hard delay before handler execution is aborted with a `HandlerTimeoutError`
160
+ - `handler_slow_timeout?: number | null` delay before emitting a slow handler warning log line
161
+ - `handler_name?: string` optional name to use instead of `anonymous` if handler is an unnamed arrow function
162
+ - `handler_file_path?: string | null` optional path/to/source/file.js:lineno where the handler is defined, used for logging only
163
+ - `id?: string` unique UUID for the handler (normally a hash of bus_id + event_pattern + handler_name + handler_registered_at)
164
+
165
+ Notes:
166
+
167
+ - Prefer class/factory keys (`bus.on(MyEvent, handler)`) for typed payload/result inference.
168
+ - String and `'*'` matching are supported (`bus.on('MyEvent', ...)`, `bus.on('*', ...)`).
169
+ - Returns an `EventHandler` object you can later pass to `off()` to de-register the handler if needed.
170
+
171
+ #### `off()`
172
+
173
+ ```ts
174
+ off<T extends BaseEvent>(
175
+ event_pattern: EventPattern<T> | '*',
176
+ handler?: EventHandlerCallable<T> | string | EventHandler
177
+ ): void
178
+ ```
179
+
180
+ Use when tearing down subscriptions (tests, plugin unload, hot-reload).
181
+
182
+ - Omit `handler` to remove all handlers for `event_pattern`.
183
+ - Pass handler function reference to remove one by function identity.
184
+ - Pass handler id (`string`) or `EventHandler` object to remove by id.
185
+ - use `bus.off('*')` to remove _all_ registered handlers from the bus
186
+
187
+ #### `emit()`
188
+
189
+ ```ts
190
+ emit<T extends BaseEvent>(event: T): T
191
+ ```
192
+
193
+ Behavior notes:
194
+
195
+ - Per-event config fields stay on the event as provided; when unset (`null`/`undefined`), each bus resolves its own defaults at processing time.
196
+ - If same event ends up forwarded through multiple buses, it is loop-protected using `event_path`.
197
+ - Emit is synchronous and returns immediately with the same event object (`event.event_status` is initially `'pending'`).
198
+
199
+ Normal lifecycle:
200
+
201
+ 1. Create event instance (`const event = MyEvent({...})`).
202
+ 2. Emit (`const queued = bus.emit(event)`).
203
+ 3. Await with `await queued.done()` (immediate/queue-jump semantics) or `await queued.eventCompleted()` (bus queue order).
204
+ 4. Inspect `queued.event_results`, `queued.event_result`, `queued.event_errors`, etc. if you need to access handler return values
205
+
206
+ #### `find()`
207
+
208
+ ```ts
209
+ find<T extends BaseEvent>(event_pattern: EventPattern<T> | '*', options?: FindOptions): Promise<T | null>
210
+ find<T extends BaseEvent>(
211
+ event_pattern: EventPattern<T> | '*',
212
+ where: (event: T) => boolean,
213
+ options?: FindOptions
214
+ ): Promise<T | null>
215
+ ```
216
+
217
+ Where:
218
+
219
+ ```ts
220
+ type FindOptions = {
221
+ past?: boolean | number // true to look through all past events, or number in seconds to filter time range
222
+ future?: boolean | number // true to wait for event to appear indefinitely, or number in seconds to wait for event to appear
223
+ child_of?: BaseEvent | null // filter to only match events that are a child_of: some_parent_event
224
+ } & {
225
+ // event_status: 'pending' | 'started' | 'completed'
226
+ // event_id: 'some-exact-event-uuid-here',
227
+ // event_started_at: string | null (exact iso datetime string or null)
228
+ // ... any event field can be passed to filter events using simple equality checks
229
+ [key: string]: unknown
230
+ }
231
+ ```
232
+
233
+ `bus.find()` returns the first matching event (in emit timestamp order).
234
+ To find multiple matching events, iterate through `bus.event_history.filter((event) => ...some condition...)` manually.
235
+
236
+ `where` behavior:
237
+ Any filter predicate function in the form of `(event) => true | false`, returning true to consider the event a match.
238
+
239
+ ```ts
240
+ const matching_event = bus.find(SomeEvent, (event) => event.some_field == 123)
241
+ // or to match all event types:
242
+ const matching_event = bus.find('*', (event) => event.some_field == 123)
243
+ ```
244
+
245
+ `past` behavior:
246
+
247
+ - `true`: search all history.
248
+ - `false`: skip searching past event history.
249
+ - `number`: search events emitted within last `N` seconds.
250
+
251
+ `future` behavior:
252
+
253
+ - `true`: wait forever for future match.
254
+ - `false`: do not wait.
255
+ - `number`: wait up to `N` seconds.
256
+
257
+ Lifecycle use:
258
+
259
+ - Use for idempotency / de-dupe before emit (`past: ...`).
260
+ - Use for synchronization/waiting (`future: ...`).
261
+ - Combine both to "check recent then wait".
262
+ - Add `child_of` to constrain by parent/ancestor event chain.
263
+ - Add any event field (e.g. `event_status`, `event_id`, `event_timeout`, `user_id`) to filter by strict equality.
264
+ - Use wildcard matching with predicates when you want to search all event types: `bus.find('*', (event) => ...)`.
265
+
266
+ Debouncing expensive events with `find()`:
267
+
268
+ ```ts
269
+ const some_expensive_event = (await bus.find(ExpensiveEvent, { past: 15, future: 5 })) ?? bus.emit(ExpensiveEvent({}))
270
+ await some_expensive_event.done()
271
+ ```
272
+
273
+ Important semantics:
274
+
275
+ - Past lookup matches any emitted events, not just completed events.
276
+ - Past/future matches resolve as soon as event is emitted. If you need the completed event, await `event.done()` or pass `{event_status: 'completed'}` to filter only for completed events.
277
+ - If both `past` and `future` are omitted, defaults are `past: true, future: false`.
278
+ - If both `past` and `future` are `false`, it returns `null` immediately.
279
+ - Detailed behavior matrix is covered in `bubus-ts/tests/eventbus_find.test.ts`.
280
+
281
+ #### `waitUntilIdle(timeout?)`
282
+
283
+ `await bus.waitUntilIdle()` is the normal "drain bus work" call to wait until bus is done processing everything queued.
284
+ Pass an optional timeout in seconds (`await bus.waitUntilIdle(5)`) for a bounded wait.
285
+
286
+ ```ts
287
+ bus.emit(OneEvent(...))
288
+ bus.emit(TwoEvent(...))
289
+ bus.emit(ThreeEvent(...))
290
+ await bus.waitUntilIdle() // this resolves once all three events have finished processing
291
+ await bus.waitUntilIdle(5) // wait up to 5 seconds, then continue even if work is still in-flight
292
+ ```
293
+
294
+ #### Parent/child/event lookup helpers
295
+
296
+ ```ts
297
+ eventIsChildOf(child_event: BaseEvent, paret_event: BaseEvent): boolean
298
+ eventIsParentOf(parent_event: BaseEvent, child_event: BaseEvent): boolean
299
+ findEventById(event_id: string): BaseEvent | null
300
+ ```
301
+
302
+ #### `toString()` / `toJSON()` / `fromJSON()`
303
+
304
+ ```ts
305
+ toString(): string
306
+ toJSON(): EventBusJSON
307
+ EventBus.fromJSON(data: unknown): EventBus
308
+ ```
309
+
310
+ - `toString()` returns `BusName#abcd` style labels used in logs/errors.
311
+ - `toJSON()` exports full bus state snapshot (config, handlers, indexes, event_history, pending queue, in-flight ids, find-waiter snapshots).
312
+ - `fromJSON()` restores a new bus instance from that payload (handler functions are restored as no-op stubs).
313
+
314
+ #### `logTree()`
315
+
316
+ ```ts
317
+ logTree(): string
318
+ ```
319
+
320
+ - `logTree()` returns a full event log hierarchy tree diagram for debugging.
321
+
322
+ #### `destroy()`
323
+
324
+ ```ts
325
+ destroy(): void
326
+ ```
327
+
328
+ - `destroy()` clears handlers/history/locks and removes this bus from global weak registry.
329
+ - `destroy()`/GC behavior is exercised in `bubus-ts/tests/eventbus.test.ts` and `bubus-ts/tests/eventbus_performance.test.ts`.
330
+
331
+ </details>
332
+
333
+ <details>
334
+ <summary><strong>Review event fields, runtime state, and helper methods.</strong></summary>
335
+
336
+ Base class + factory builder for typed event models.
337
+
338
+ Define your own strongly typed events with `BaseEvent.extend('EventName', {...zod fields...})`:
339
+
340
+ ```ts
341
+ const MyEvent = BaseEvent.extend('MyEvent', {
342
+ some_key: z.string(),
343
+ some_other_key: z.number(),
344
+ // ...
345
+ // any other payload fields you want to include can go here
346
+
347
+ // fields that start with event_* are reserved for metadata used by the library
348
+ event_result_type: z.string().optional(),
349
+ event_timeout: 60,
350
+ // ...
83
351
  })
84
352
 
85
- // Per-handler override (lower precedence)
86
- bus.on(FastEvent, handler, { event_handler_concurrency: 'parallel' })
353
+ const pending_event = MyEvent({ some_key: 'abc', some_other_key: 234 })
354
+ const queued_event = bus.emit(pending_event)
355
+ const completed_event = await queued_event.done()
356
+ ```
357
+
358
+ API behavior and lifecycle examples:
359
+
360
+ - `bubus-ts/examples/simple.ts`
361
+ - `bubus-ts/examples/immediate_event_processing.ts`
362
+ - `bubus-ts/examples/forwarding_between_busses.ts`
363
+ - `bubus-ts/tests/eventbus.test.ts`
364
+ - `bubus-ts/tests/eventbus_find.test.ts`
365
+ - `bubus-ts/tests/event_handler_first.test.ts`
366
+ - `bubus-ts/tests/base_event_event_bus_proxy.test.ts`
367
+ - `bubus-ts/tests/eventbus_timeout.test.ts`
368
+ - `bubus-ts/tests/event_result.test.ts`
369
+
370
+ #### Event configuration fields
371
+
372
+ Special configuration fields you can set on each event to control processing:
373
+
374
+ - `event_result_type?: z.ZodTypeAny | String | Number | Boolean | Array | Object`
375
+ - `event_version?: string` (default: `'0.0.1'`; useful for your own schema/data migrations)
376
+ - `event_timeout?: number | null`
377
+ - `event_handler_timeout?: number | null`
378
+ - `event_handler_slow_timeout?: number | null`
379
+ - `event_concurrency?: 'global-serial' | 'bus-serial' | 'parallel' | null`
380
+ - `event_handler_concurrency?: 'serial' | 'parallel' | null`
381
+ - `event_handler_completion?: 'all' | 'first'`
382
+
383
+ #### Runtime state fields
384
+
385
+ - `event_id`, `event_type`, `event_version`
386
+ - `event_path: string[]` (bus labels like `BusName#ab12`)
387
+ - `event_parent_id: string | null`
388
+ - `event_emitted_by_handler_id: string | null`
389
+ - `event_status: 'pending' | 'started' | 'completed'`
390
+ - `event_results: Map<string, EventResult>`
391
+ - `event_pending_bus_count: number`
392
+ - `event_created_at: string`
393
+ - `event_started_at: string | null`
394
+ - `event_completed_at: string | null`
395
+
396
+ #### Read-only attributes
397
+
398
+ - `event_parent` -> `BaseEvent | undefined`
399
+ - `event_children` -> `BaseEvent[]`
400
+ - `event_descendants` -> `BaseEvent[]`
401
+ - `event_errors` -> `Error[]`
402
+ - `event_result` -> `EventResultType<this> | undefined`
403
+
404
+ #### `done()`
405
+
406
+ ```ts
407
+ done(): Promise<this>
87
408
  ```
88
409
 
89
- Precedence order (highest lowest):
410
+ - If called from inside a running handler, it queue-jumps child processing immediately.
411
+ - If called outside handler context, it waits for normal completion (or processes immediately if already next).
412
+ - Rejects if event is not attached to a bus (`event has no bus attached`).
413
+ - Queue-jump behavior is demonstrated in `bubus-ts/examples/immediate_event_processing.ts` and `bubus-ts/tests/base_event_event_bus_proxy.test.ts`.
90
414
 
91
- 1. Event instance overrides (`event_concurrency`, `event_handler_concurrency`)
92
- 2. Handler options (`event_handler_concurrency`)
93
- 3. Bus defaults (`event_concurrency`, `event_handler_concurrency`)
415
+ #### `eventCompleted()`
94
416
 
95
- `"auto"` resolves to the bus default.
417
+ ```ts
418
+ eventCompleted(): Promise<this>
419
+ ```
420
+
421
+ - Waits for completion in normal runloop order.
422
+ - Use inside handlers when you explicitly do not want queue-jump behavior.
423
+
424
+ #### `first()`
425
+
426
+ ```ts
427
+ first(): Promise<EventResultType<this> | undefined>
428
+ ```
429
+
430
+ - Forces `event_handler_completion = 'first'` for this run.
431
+ - Returns temporally first non-`undefined` successful handler result.
432
+ - Cancels pending/running losing handlers on the same bus.
433
+ - Returns `undefined` when no handler produces a successful non-`undefined` value.
434
+ - Cancellation and winner-selection behavior is covered in `bubus-ts/tests/event_handler_first.test.ts`.
435
+
436
+ #### `eventResultsList(include?, options?)`
437
+
438
+ ```ts
439
+ eventResultsList(
440
+ include?: (result: EventResultType<this> | undefined, event_result: EventResult<this>) => boolean,
441
+ options?: {
442
+ timeout?: number | null
443
+ include?: (result: EventResultType<this> | undefined, event_result: EventResult<this>) => boolean
444
+ raise_if_any?: boolean
445
+ raise_if_none?: boolean
446
+ }
447
+ ): Promise<Array<EventResultType<this> | undefined>>
448
+ ```
96
449
 
97
- ## Handler Options
450
+ - Returns handler result values in `event_results` order.
451
+ - Default filter includes completed non-`null`/non-`undefined` non-error, non-forwarded (`BaseEvent`) values.
452
+ - `raise_if_any` defaults to `true` and throws when any handler result has an error.
453
+ - `raise_if_none` defaults to `true` and throws when no results match `include`.
454
+ - `timeout` is in seconds and bounds how long to wait for completion.
455
+ - Examples:
456
+ - `await event.eventResultsList({ raise_if_any: false, raise_if_none: false })`
457
+ - `await event.eventResultsList((result) => typeof result === 'object', { raise_if_any: false })`
98
458
 
99
- Handlers can be configured at registration time:
459
+ #### `eventResultUpdate(handler, options?)`
100
460
 
101
461
  ```ts
102
- bus.on(SomeEvent, handler, {
462
+ eventResultUpdate(
463
+ handler: EventHandler | EventHandlerCallable<this>,
464
+ options?: {
465
+ eventbus?: EventBus
466
+ status?: 'pending' | 'started' | 'completed' | 'error'
467
+ result?: EventResultType<this> | BaseEvent | undefined
468
+ error?: unknown
469
+ }
470
+ ): EventResult<this>
471
+ ```
472
+
473
+ - Creates (if missing) or updates one `event_results` entry for the given handler id.
474
+ - Useful for deterministic seeding/rehydration paths before resuming normal dispatch.
475
+ - Example:
476
+ - `const seeded = event.eventResultUpdate(handler_entry, { eventbus: bus, status: 'pending' })`
477
+ - `seeded.update({ status: 'completed', result: 'seeded' })`
478
+
479
+ #### `reset()`
480
+
481
+ ```ts
482
+ reset(): this
483
+ ```
484
+
485
+ - Returns a fresh event copy with runtime state reset to pending so it can be emitted again safely.
486
+ - Original event object is unchanged.
487
+ - Generates a new UUIDv7 `event_id` for the returned copy.
488
+ - Clears runtime completion state (`event_results`, status/timestamps, captured async context, done signal, local bus binding).
489
+
490
+ #### `toString()` / `toJSON()` / `fromJSON()`
491
+
492
+ ```ts
493
+ toString(): string
494
+ toJSON(): BaseEventData
495
+ BaseEvent.fromJSON(data: unknown): BaseEvent
496
+ EventFactory.fromJSON?.(data: unknown): TypedEvent
497
+ ```
498
+
499
+ - JSON format is cross-language compatible with Python implementation.
500
+ - `event_result_type` is serialized as JSON Schema when possible and rehydrated on `fromJSON`.
501
+ - In TypeScript-only usage, `event_result_type` can be any Zod schema shape or base type like `number | string | boolean | etc.`. For cross-language roundtrips, object-like schemas (including Python `TypedDict`/`dataclass`-style shapes) are reconstructed on Python as Pydantic models, JSON object keys are always strings, and some fine-grained string-shape constraints may be normalized between Zod and Pydantic.
502
+ - Round-trip coverage is in `bubus-ts/tests/event_result_typed_results.test.ts` and `bubus-ts/tests/eventbus.test.ts`.
503
+
504
+ </details>
505
+
506
+ <details>
507
+ <summary><strong>Review per-handler status, timing, outputs, and captured errors.</strong></summary>
508
+
509
+ Each handler execution creates one `EventResult` stored in `event.event_results`.
510
+
511
+ #### Main fields
512
+
513
+ - `id: string` (uuidv7 string)
514
+ - `status: 'pending' | 'started' | 'completed' | 'error'`
515
+ - `event: BaseEvent`
516
+ - `handler: EventHandler`
517
+ - `result: EventResultType<this> | undefined`
518
+ - `error: unknown | undefined`
519
+ - `started_at: string | null` (ISO datetime string)
520
+ - `completed_at: string | null` (ISO datetime string)
521
+ - `event_children: BaseEvent[]`
522
+
523
+ #### Read-only getters
524
+
525
+ - `event_id` -> `string` uuiv7 of the event the result is for
526
+ - `bus` -> `EventBus` instance it's associated with
527
+ - `handler_id` -> `string` uuidv5 of the `EventHandler`
528
+ - `handler_name` -> `string | 'anonymous'` function name of the handler method
529
+ - `handler_file_path` -> `string | null` path/to/file.js:lineno where the handler method is defined
530
+ - `eventbus_name` -> `string` name, same as `this.bus.name`
531
+ - `eventbus_id` -> `string` uuidv7, same as `this.bus.id`
532
+ - `eventbus_label` -> `string` label, same as `this.bus.label`
533
+ - `value` -> `EventResultType<this> | undefined` alias of `this.result`
534
+ - `raw_value` -> `any` raw result value before schema validation, available when handler return value validation fails
535
+ - `handler_timeout` -> `number` seconds before handler execution is aborted (precedence: handler config -> event config -> bus level defaults)
536
+ - `handler_slow_timeout` -> `number` seconds before logging a slow execution warning (same prececence as `handler_timeout`)
537
+
538
+ #### `toString()` / `toJSON()` / `fromJSON()`
539
+
540
+ ```ts
541
+ toString(): string
542
+ toJSON(): EventResultJSON
543
+ EventResult.fromJSON(event, data): EventResult
544
+ ```
545
+
546
+ </details>
547
+
548
+ <details>
549
+ <summary><strong>Review handler metadata, registration fields, and serialization helpers.</strong></summary>
550
+
551
+ Represents one registered handler entry on a bus. You usually get these from `bus.on(...)`, then pass them to `bus.off(...)` to remove.
552
+
553
+ #### Main fields
554
+
555
+ - `id` unique handler UUIDv5 (deterministic hash from bus/event/handler metadata unless overridden)
556
+ - `handler` function reference that executes for matching events
557
+ - `handler_name` function name (or `'anonymous'`)
558
+ - `handler_file_path` detected source path (`~/path/file.ts:line`) or `null`
559
+ - `handler_timeout` optional timeout override in seconds (`null` disables timeout limit)
560
+ - `handler_slow_timeout` optional slow-warning threshold in seconds (`null` disables slow warning)
561
+ - `handler_registered_at` ISO timestamp
562
+ - `event_pattern` subscribed key (`'SomeEvent'` or `'*'`)
563
+ - `eventbus_name` bus name where this handler was registered
564
+ - `eventbus_id` bus UUID where this handler was registered
565
+
566
+ #### `toString()` / `toJSON()` / `fromJSON()`
567
+
568
+ ```ts
569
+ toString(): string
570
+ toJSON(): EventHandlerJSON
571
+ EventHandler.fromJSON(data: unknown, handler?: EventHandlerCallable): EventHandler
572
+ ```
573
+
574
+ - `toString()` returns `handlerName() (path:line)` when path/name are available, otherwise `function#abcd()`.
575
+ - `toJSON()` emits only serializable handler metadata (never function bodies).
576
+ - `fromJSON()` reconstructs the handler entry and accepts an optional real function to re-bind execution behavior.
577
+
578
+ <br/>
579
+
580
+ ---
581
+
582
+ <br/>
583
+
584
+ </details>
585
+
586
+ ## 🧵 Advanced Concurrency Control
587
+
588
+ ### Concurrency Config Options
589
+
590
+ #### Bus-level config options (`new EventBus(name, {...options...})`)
591
+
592
+ - `max_history_size?: number | null` (default: `100`)
593
+ - Max events kept in history. `null` = unlimited. `bus.find(...)` uses this log to query recently emitted events
594
+ - `0` keeps only pending/in-flight events; each event is removed from history immediately after completion.
595
+ - `max_history_drop?: boolean` (default: `false`)
596
+ - If `true`, drop oldest history entries when history is full (including uncompleted entries if needed).
597
+ - If `false`, reject new emits when history is full.
598
+ - `event_concurrency?: 'global-serial' | 'bus-serial' | 'parallel' | null` (default: `'bus-serial'`)
599
+ - Event-level scheduling policy (`global-serial`: FIFO across all buses, `bus-serial`: FIFO per bus, `parallel`: concurrent events per bus).
600
+ - `event_handler_concurrency?: 'serial' | 'parallel' | null` (default: `'serial'`)
601
+ - Handler-level scheduling policy for each event (`serial`: one handler at a time per event, `parallel`: all handlers for the event can run concurrently).
602
+ - `event_handler_completion?: 'all' | 'first'` (default: `'all'`)
603
+ - Completion strategy (`all`: wait for all handlers, `first`: stop after first non-`undefined` result).
604
+ - `event_timeout?: number | null` (default: `60`)
605
+ - Default handler timeout budget in seconds.
606
+ - `event_handler_slow_timeout?: number | null` (default: `30`)
607
+ - Slow-handler warning threshold in seconds.
608
+ - `event_slow_timeout?: number | null` (default: `300`)
609
+ - Slow-event warning threshold in seconds.
610
+
611
+ #### Event-level config options
612
+
613
+ Override the bus defaults on a per-event basis by using these special fields in the event:
614
+
615
+ ```ts
616
+ const event = MyEvent({
617
+ event_concurrency: 'parallel',
103
618
  event_handler_concurrency: 'parallel',
104
- handler_timeout: 10, // per-handler timeout in seconds
619
+ event_handler_completion: 'first',
620
+ event_timeout: 10,
621
+ event_handler_timeout: 3,
105
622
  })
106
623
  ```
107
624
 
108
- - `event_handler_concurrency` allows per-handler concurrency overrides.
109
- - `handler_timeout` sets a per-handler timeout in seconds (overrides the bus default when lower).
110
-
111
- ## TypeScript Return Type Enforcement (Edge Cases)
112
-
113
- TypeScript can only enforce handler return types when the event type is inferable at compile time.
114
-
115
- - `bus.on(EventFactoryOrClass, handler)`:
116
- - Return values are type-checked against the event's `event_result_schema` (if defined).
117
- - `undefined` (or no return) is always allowed.
118
- - `bus.on('SomeEventName', handler)`:
119
- - Return type checking is best-effort only (treated as unknown in typing).
120
- - Use class/factory keys when you want compile-time return-shape enforcement.
121
- - `bus.on('*', handler)`:
122
- - Return type checking is intentionally loose (best-effort only), because wildcard handlers may receive many event types, including forwarded events from other buses.
123
- - In practice, wildcard handlers are expected to be side-effect/forwarding handlers and usually return `undefined`.
124
-
125
- Runtime behavior is still consistent across all key styles:
126
-
127
- - If an event has `event_result_schema` and a handler returns a non-`undefined` value, that value is validated at runtime.
128
- - If the handler returns `undefined`, schema validation is skipped and the result is accepted.
129
-
130
- ## Throughput + Memory Behavior (Current)
131
-
132
- This section documents the current runtime profile and the important edge cases. It is intentionally conservative:
133
- we describe what is enforced today, not theoretical best-case behavior.
134
-
135
- ### Throughput model
136
-
137
- - Baseline throughput in tests is gated at `<30s` for:
138
- - `50k events within reasonable time`
139
- - `50k events with ephemeral on/off handler registration across 2 buses`
140
- - `500 ephemeral buses with 100 events each`
141
- - The major hot-path operations are linear in collection sizes:
142
- - Per event, handler matching is `O(total handlers on bus)` (`exact` scan + `*` scan).
143
- - `.off()` is `O(total handlers on bus)` for matching/removal.
144
- - Queue-jump (`await event.done()` inside handlers) does cross-bus discovery by walking `event_path` and iterating `EventBus._all_instances`, so cost grows with buses and forwarding depth.
145
- - `waitUntilIdle()` is best used at batch boundaries, not per event:
146
- - Idle checks call `isIdle()`, which scans `event_history` and handler results.
147
- - There is a fast-path that skips idle scans when no idle waiters exist, which keeps normal dispatch/complete flows fast even with large history.
148
- - Concurrency settings are a direct throughput limiter:
149
- - `global-serial` and `bus-serial` intentionally serialize work.
150
- - `parallel` increases throughput but can increase transient memory if producers outpace consumers.
151
-
152
- ### Memory model
153
-
154
- - Per bus, strong references are held for:
155
- - `handlers`
156
- - `pending_event_queue`
157
- - `in_flight_event_ids`
158
- - `event_history` (bounded by `max_history_size`, or unbounded if `null`)
159
- - active `find()` waiters until match/timeout
160
- - Per event, retained state includes:
161
- - `event_results` (per-handler result objects)
162
- - descendant links in `event_results[].event_children`
163
- - History trimming behavior:
164
- - Completed events are evicted first (oldest first).
165
- - If still over limit, oldest remaining events are dropped even if pending, and a warning is logged.
166
- - Eviction calls `event._gc()` to clear internal references (`event_results`, child arrays, bus/context pointers).
167
- - Memory is not strictly bounded by only `pending_queue_size + max_history_size`:
168
- - A retained parent event can hold references to many children/grandchildren via `event_children`.
169
- - So effective retained memory can exceed a simple `event_count * avg_event_size` bound in high fan-out trees.
170
- - `destroy()` is recommended for deterministic cleanup, but not required for GC safety:
171
- - `_all_instances` is WeakRef-based, so unreferenced buses can be collected without calling `.destroy()`.
172
- - There is a GC regression test for this (`unreferenced buses with event history are garbage collected without destroy()`).
173
- - `heapUsed` vs `rss`:
174
- - `heapUsed` returning near baseline after GC is the primary leak signal in tests.
175
- - `rss` can stay elevated due to V8 allocator high-water behavior and is not, by itself, a proof of leak.
625
+ Notes:
626
+
627
+ - `null` means "inherit/fall back to bus default" for event-level concurrency and timeout fields.
628
+ - Forwarded events are processed under the target bus's config; source bus config is not inherited.
629
+ - `event_handler_completion` is independent from handler scheduling mode (`serial` vs `parallel`).
630
+
631
+ #### Handler-level config options
632
+
633
+ Set at registration:
634
+
635
+ ```ts
636
+ bus.on(MyEvent, handler, { handler_timeout: 2 }) // max time in seconds this handler is allowed to run before it's aborted
637
+ ```
638
+
639
+ #### Precedence and interaction
640
+
641
+ Event and handler concurrency precedence:
642
+
643
+ 1. Event instance override (`event.event_concurrency`, `event.event_handler_concurrency`)
644
+ 2. Bus defaults (`EventBus` options)
645
+ 3. Built-in defaults (`bus-serial`, `serial`)
646
+
647
+ Timeout resolution for each handler run:
176
648
 
177
- ### Practical guidance for high-load deployments
649
+ 1. Resolve handler timeout source:
650
+ - `bus.on(..., { handler_timeout })`
651
+ - else `event.event_handler_timeout`
652
+ - else bus `event_timeout`
653
+ 2. Apply event cap:
654
+ - effective timeout is `min(resolved_handler_timeout, event.event_timeout)` when both are non-null
655
+ - if either is `null`, the non-null value wins; both null means no timeout
178
656
 
179
- - Keep `max_history_size` finite in production.
180
- - Avoid very large wildcard handler sets on hot event types.
181
- - Avoid calling `waitUntilIdle()` for every single event in large streams; prefer periodic/batch waits.
182
- - Be aware that very deep/high-fan-out parent-child graphs increase retained memory until parent events are evicted.
183
- - Use `.destroy()` for explicit lifecycle control in request-scoped or short-lived bus patterns.
657
+ Additional timeout nuance:
184
658
 
185
- ## Semaphores (how concurrency is enforced)
659
+ - `BaseEvent.event_timeout` starts as `null` unless set; each processing bus resolves its own `event_timeout` default when still unset.
660
+ - Bus/event timeouts are outer budgets for handler execution; use `@retry({ timeout })` for per-attempt timeouts.
186
661
 
187
- We use four semaphores:
662
+ Use `@retry` for per-handler execution timeout/retry/backoff/semaphore control. Keep bus/event timeouts as outer execution budgets.
188
663
 
189
- - `LockManager.global_event_semaphore`
190
- - `LockManager.global_handler_semaphore`
191
- - `bus.locks.bus_event_semaphore`
192
- - `bus.locks.bus_handler_semaphore`
664
+ ### Runtime lifecycle (bus -> event -> handler)
193
665
 
194
- They are applied centrally when scheduling events and handlers, so concurrency is controlled without scattering
195
- mutex checks throughout the code.
666
+ Emit flow:
196
667
 
197
- ## Full lifecycle across concurrency modes
668
+ 1. `emit()` normalizes to original event and captures async context when available.
669
+ 2. Bus appends itself to `event_path` and records runtime ownership for this processing pass.
670
+ 3. Event enters `event_history`, `pending_event_queue`, and runloop starts.
671
+ 4. Runloop dequeues and calls `processEvent()`.
672
+ 5. Event-level semaphore (`event_concurrency`) is applied.
673
+ 6. Handler results are created and executed under handler-level semaphore (`event_handler_concurrency`), with timeout/concurrency defaults resolved at processing time on the current bus when event fields are unset.
674
+ 7. Event completion and child completion propagate through `event_pending_bus_count` and result states.
675
+ 8. History trimming evicts completed events first; if still over limit, oldest pending events can be dropped (with warning), then cleanup runs.
198
676
 
199
- Below is the complete execution flow for nested events, including forwarding across buses, and how it behaves
200
- under different `event_concurrency` / `event_handler_concurrency` configurations.
677
+ Locking model:
201
678
 
202
- ### 1) Base execution flow (applies to all modes)
679
+ - Global event semaphore: `global-serial`
680
+ - Bus event semaphore: `bus-serial`
681
+ - Per-event handler semaphore: `serial` handler mode
203
682
 
204
- **Dispatch (non-awaited):**
683
+ ### Queue-jumping (`await event.done()` inside handlers)
205
684
 
206
- 1. `dispatch()` normalizes to `original_event`, sets `bus` if missing.
207
- 2. Captures `_dispatch_context` (AsyncLocalStorage if available).
208
- 3. Applies `event_timeout_default` if `event.event_timeout === null`.
209
- 4. If this bus is already in `event_path` (or `bus.hasProcessedEvent()`), return a BusScopedEvent without queueing.
210
- 5. Append bus name to `event_path`, record child relationship (if `event_parent_id` is set).
211
- 6. Add to `event_history` (a `Map<string, BaseEvent>` keyed by event id).
212
- 7. Increment `event_pending_bus_count`.
213
- 8. Push to `pending_event_queue` and `startRunloop()`.
685
+ Want to emit and await an event like a function call? simply `await event.done()`.
686
+ When called inside a handler, the awaited event is processed immediately (queue-jump behavior) before normal queued work continues.
214
687
 
215
- **Runloop + processing:**
688
+ ### `@retry` Decorator
216
689
 
217
- 1. `runloop()` drains `pending_event_queue`.
218
- 2. Adds event id to `in_flight_event_ids`.
219
- 3. Calls `scheduleEventProcessing()` (async).
220
- 4. `scheduleEventProcessing()` selects the event semaphore and runs `processEvent()`.
221
- 5. `processEvent()`:
222
- - `event.markStarted()`
223
- - `notifyFindListeners(event)`
224
- - creates handler results (`event_results`)
225
- - runs handlers (respecting handler semaphore)
226
- - decrements `event_pending_bus_count` and calls `event.markCompleted(false)` (completes only if all buses and children are done)
690
+ `retry()` adds retry logic and optional semaphore-based concurrency limiting to async functions/handlers.
227
691
 
228
- ### 2) Event concurrency modes (`event_concurrency`)
692
+ #### Why retry is handler-level
229
693
 
230
- - **`global-serial`**: events are serialized across _all_ buses using `LockManager.global_event_semaphore`.
231
- - **`bus-serial`**: events are serialized per bus; different buses can overlap.
232
- - **`parallel`**: no event semaphore; events can run concurrently on the same bus.
233
- - **`auto`**: resolves to the bus default.
694
+ Retry and timeout belong on handlers, not emit sites:
234
695
 
235
- **Mixed buses:** each bus enforces its own event mode. Forwarding to another bus does not inherit the source bus’s mode.
696
+ - Handlers fail; events are messages.
697
+ - Handler-level retries preserve replay semantics (one event emit, internal retry attempts).
698
+ - Bus concurrency and retry concerns are orthogonal and compose cleanly.
236
699
 
237
- ### 3) Handler concurrency modes (`event_handler_concurrency`)
700
+ #### Recommended pattern: `@retry()` on class methods
238
701
 
239
- `event_handler_concurrency` controls how handlers run **for a single event**:
702
+ ```ts
703
+ import { retry, EventBus } from 'bubus'
704
+
705
+ class ScreenshotService {
706
+ constructor(private bus: InstanceType<typeof EventBus>) {
707
+ bus.on(ScreenshotRequestEvent, this.onScreenshot.bind(this))
708
+ }
709
+
710
+ @retry({
711
+ max_attempts: 4,
712
+ retry_on_errors: [/timeout/i],
713
+ timeout: 5,
714
+ semaphore_scope: 'global',
715
+ semaphore_name: 'Screenshots',
716
+ semaphore_limit: 2,
717
+ })
718
+ async onScreenshot(event: InstanceType<typeof ScreenshotRequestEvent>): Promise<Buffer> {
719
+ return await takeScreenshot(event.data.url)
720
+ }
721
+ }
722
+
723
+ const ev = bus.emit(ScreenshotRequestEvent({ url: 'https://example.com' }))
724
+ await ev.done()
725
+ ```
726
+
727
+ #### Also works: inline HOF
728
+
729
+ ```ts
730
+ bus.on(
731
+ MyEvent,
732
+ retry({ max_attempts: 3, timeout: 10 })(async (event) => {
733
+ await riskyOperation(event.data)
734
+ })
735
+ )
736
+ ```
240
737
 
241
- - **`global-serial`**: only one handler at a time across all buses using `LockManager.global_handler_semaphore`.
242
- - **`bus-serial`**: handlers serialize per bus.
243
- - **`parallel`**: handlers run concurrently for the event.
244
- - **`auto`**: resolves to the bus default.
738
+ #### Options
245
739
 
246
- **Interaction with event concurrency:**
247
- Even if events are parallel, handlers can still be serialized:
248
- `event_concurrency: "parallel"` + `event_handler_concurrency: "bus-serial"` means events start concurrently but handler execution on a bus is serialized.
740
+ | Option | Type | Default | Description |
741
+ | ---------------------- | ----------------------------------------- | ----------- | ----------------------------------------------- |
742
+ | `max_attempts` | `number` | `1` | Total attempts including first call. |
743
+ | `retry_after` | `number` | `0` | Seconds between retries. |
744
+ | `retry_backoff_factor` | `number` | `1.0` | Multiplier for retry delay. |
745
+ | `retry_on_errors` | `(ErrorClass \| string \| RegExp)[]` | `undefined` | Retry filter. `undefined` retries on any error. |
746
+ | `timeout` | `number \| null` | `undefined` | Per-attempt timeout in seconds. |
747
+ | `semaphore_limit` | `number \| null` | `undefined` | Max concurrent executions sharing semaphore. |
748
+ | `semaphore_name` | `string \| ((...args) => string) \| null` | fn name | Semaphore key. |
749
+ | `semaphore_lax` | `boolean` | `true` | Continue if semaphore acquisition times out. |
750
+ | `semaphore_scope` | `'global' \| 'class' \| 'instance'` | `'global'` | Scope for semaphore identity. |
751
+ | `semaphore_timeout` | `number \| null` | `undefined` | Max seconds waiting for semaphore. |
249
752
 
250
- ### 4) Forwarding across buses (non-awaited)
753
+ #### Error types
251
754
 
252
- When a handler on Bus A calls `bus_b.dispatch(event)` without awaiting:
755
+ - `RetryTimeoutError`: per-attempt timeout exceeded.
756
+ - `SemaphoreTimeoutError`: semaphore acquisition timeout (`semaphore_lax=false`).
253
757
 
254
- - Bus A continues running its handler.
255
- - Bus B queues and processes the event according to **Bus B’s** concurrency settings.
256
- - No coupling unless both buses use the global semaphores.
758
+ #### Re-entrancy
257
759
 
258
- ### 5) Queue-jump (`await event.done()` inside handlers)
760
+ On Node.js/Bun, `AsyncLocalStorage` tracks held semaphores and avoids deadlocks for nested calls using the same semaphore.
761
+ In browsers, this tracking is unavailable, avoid recursive/nested same-semaphore patterns there.
259
762
 
260
- When `event.done()` is awaited inside a handler, **queue-jump** happens:
763
+ #### Interaction with bus concurrency
261
764
 
262
- 1. `BaseEvent.done()` delegates to `bus.processEventImmediately()`, which detects whether we're inside a handler
263
- (via `getActiveHandlerResult()` / `getParentEventResultAcrossAllBusses()`). If not inside a handler, it falls back to `waitForCompletion()`.
264
- 2. `processEventImmediately()` **yields** the parent handler's concurrency semaphore (if held) so child handlers can acquire it.
265
- 3. `processEventImmediately()` removes the event from the pending queue (if present).
266
- 4. `runImmediatelyAcrossBuses()` processes the event immediately on all buses where it is queued.
267
- 5. While immediate processing is active, each affected bus's runloop is paused to prevent unrelated events from running.
268
- 6. Once immediate processing completes, `processEventImmediately()` **re-acquires** the parent handler's semaphore
269
- (unless the parent timed out while the child was processing).
270
- 7. Paused runloops resume.
765
+ Execution order when used on bus handlers:
271
766
 
272
- **Important:** queue-jump bypasses event semaphores but **respects** handler semaphores via yield-and-reacquire.
273
- This means queue-jumped handlers run serially on a `bus-serial` bus, not in parallel.
767
+ 1. Bus acquires handler semaphore (`event_handler_concurrency`)
768
+ 2. `retry()` acquires retry semaphore (if configured)
769
+ 3. Handler executes (with retries)
770
+ 4. `retry()` releases retry semaphore
771
+ 5. Bus releases handler semaphore
274
772
 
275
- ### 6) Precedence recap
773
+ Use bus/event timeouts for outer deadlines and `retry({ timeout })` for per-handler-attempt deadlines.
276
774
 
277
- Highest lowest:
775
+ #### Discouraged: retrying emit sites
278
776
 
279
- 1. Event instance fields (`event_concurrency`, `event_handler_concurrency`)
280
- 2. Handler options (`event_handler_concurrency`)
281
- 3. Bus defaults
777
+ Avoid wrapping `emit()/done()` in `retry()` unless you intentionally want multiple event emits (a new event for every retry).
778
+ Keep retries on handlers so that your logs represent the original high-level intent, with a single event per call even if handling it took multiple tries.
779
+ Emitting a new event for each retry is only recommended if you are using the logs for debugging more than for replayability / time-travel.
282
780
 
283
- `"auto"` always resolves to the bus default.
781
+ <br/>
284
782
 
285
- ## Gotchas and Design Choices (What surprised us)
783
+ ---
286
784
 
287
- ### A) Handler attribution without AsyncLocalStorage
785
+ <br/>
288
786
 
289
- We need to know **which handler emitted a child** to correctly assign:
787
+ ## Bridges
290
788
 
291
- - `event_parent_id`
292
- - `event_emitted_by_handler_id`
293
- - and to attach child events under the correct handler in the tree.
789
+ Bridges are optional extra connectors provided that allow you to send/receive events from an external service, and you do not need to use a bridge to use bubus since it's normally purely in-memory. These are just simple helpers to forward bubus events JSON to storage engines / other processes / other machines; they prevent loops automatically, but beyond that it's only basic forwarding with no handler pickling or anything fancy.
294
790
 
295
- In TS we do this by injecting a **BusScopedEvent** into handlers, which captures the active handler id and
296
- propagates it via `event_emitted_by_handler_id`. This keeps parentage deterministic even with nested awaits.
791
+ Bridges all expose a very simple bus-like API with only `.emit()` and `.on()`.
297
792
 
298
- ### B) Why runloop pausing exists
793
+ **Example usage: link a bus to a redis pub/sub channel**
299
794
 
300
- When an event is awaited inside a handler, the event must **jump the queue**. If the runloop continues normally,
301
- it could process unrelated events ("overshoot"), breaking FIFO guarantees.
795
+ ```ts
796
+ const bridge = new RedisEventBridge('redis://redis@localhost:6379')
797
+
798
+ bus.on('*', bridge.emit) // listen for all events on bus and send them to redis channel
799
+ bridge.on('*', bus.emit) // listen for new events in redis channel and emit them on our bus
800
+ ```
302
801
 
303
- The `LockManager` pause mechanism (`requestPause`/`waitUntilRunloopResumed`) pauses the runloop while we run the awaited
304
- event immediately. Once the queue-jump completes, the runloop resumes in FIFO order. This matches the Python behavior.
802
+ - `new SocketEventBridge('/tmp/bubus_events.sock')`
803
+ - `new HTTPEventBridge({ send_to: 'https://127.0.0.1:8001/bubus_events', listen_on: 'http://0.0.0.0:8002/bubus_events' })`
804
+ - `new JSONLEventBridge('/tmp/bubus_events.jsonl')`
805
+ - `new SQLiteEventBridge('/tmp/bubus_events.sqlite3')`
806
+ - `new PostgresEventBridge('postgresql://user:pass@localhost:5432/dbname/bubus_events')`
807
+ - `new RedisEventBridge('redis://user:pass@localhost:6379/1/bubus_events')`
808
+ - `new NATSEventBridge('nats://localhost:4222', 'bubus_events')`
305
809
 
306
- ### C) BusScopedEvent: why it exists and how it works
810
+ <br/>
307
811
 
308
- Forwarding exposes a subtle bug: if you pass the **same event object** to another bus, a naive implementation
309
- can mutate `event.bus` mid-handler and break parent-child tracking.
812
+ ---
310
813
 
311
- To prevent that:
814
+ <br/>
312
815
 
313
- - Handlers always receive a **BusScopedEvent** (Proxy of the original event).
314
- - Its `bus` property is a proxy over the real `EventBus`.
315
- - That proxy intercepts `emit/dispatch` to set `event_parent_id` and attach children to the correct handler.
316
- - The original event object is still the canonical one stored in history.
816
+ ## 🏃 Runtimes
317
817
 
318
- ### D) Cross-bus immediate processing (forwarding + awaiting)
818
+ `bubus-ts` supports all major JS runtimes.
319
819
 
320
- When you `await event.done()` inside a handler:
820
+ - Node.js (default development and test runtime)
821
+ - Browsers (ESM)
822
+ - Bun
823
+ - Deno
321
824
 
322
- - the system finds all buses that have this event queued (using `EventBus._all_instances` + `event_path`)
323
- - pauses their runloops
324
- - processes the event immediately on each bus
325
- - then resumes the runloops
825
+ ### Browser support notes
326
826
 
327
- This gives the same "awaited events jump the queue" semantics as Python, but without a global lock.
827
+ - The package output is ESM (`./dist/esm`) which is supported by all browsers [released after 2018](https://caniuse.com/?search=ESM)
828
+ - `AsyncLocalStorage` is preserved at emit and used during handling when available (Node/Bun), otel/tracing context will work normally in those environments
328
829
 
329
- ### E) Why `event.bus` is required for `done()`
830
+ ### Performance comparison (local run, per-event)
330
831
 
331
- `done()` is the signal to run an event immediately when called inside a handler. Without a bus, we can't
332
- perform the queue jump, so `done()` throws if no bus is attached.
832
+ Measured locally on an `Apple M4 Pro` with:
333
833
 
334
- ## Summary
834
+ - `pnpm run perf:node` (`node v22.21.1`)
835
+ - `pnpm run perf:bun` (`bun v1.3.9`)
836
+ - `pnpm run perf:deno` (`deno v2.6.8`)
837
+ - `pnpm run perf:browser` (`chrome v145.0.7632.6`)
335
838
 
336
- The core contract is preserved:
839
+ | Runtime | 1 bus x 50k events x 1 handler | 500 buses x 100 events x 1 handler | 1 bus x 1 event x 50k parallel handlers | 1 bus x 50k events x 50k one-off handlers | Worst case (N buses x N events x N handlers) |
840
+ | ------------------ | ------------------------------ | ---------------------------------- | --------------------------------------- | ----------------------------------------- | -------------------------------------------- |
841
+ | Node | `0.015ms/event`, `0.6kb/event` | `0.058ms/event`, `0.1kb/event` | `0.021ms/handler`, `3.8kb/handler` | `0.028ms/event`, `0.6kb/event` | `0.442ms/event`, `0.9kb/event` |
842
+ | Bun | `0.011ms/event`, `2.5kb/event` | `0.054ms/event`, `1.0kb/event` | `0.006ms/handler`, `4.5kb/handler` | `0.019ms/event`, `2.8kb/event` | `0.441ms/event`, `3.1kb/event` |
843
+ | Deno | `0.018ms/event`, `1.2kb/event` | `0.063ms/event`, `0.4kb/event` | `0.024ms/handler`, `3.1kb/handler` | `0.064ms/event`, `2.6kb/event` | `0.461ms/event`, `7.9kb/event` |
844
+ | Browser (Chromium) | `0.030ms/event` | `0.197ms/event` | `0.022ms/handler` | `0.022ms/event` | `1.566ms/event` |
337
845
 
338
- - FIFO order
339
- - child event tracking
340
- - forwarding
341
- - await-inside-handler queue jump
846
+ Notes:
342
847
 
343
- But the **implementation details are different** because JS needs browser compatibility and lacks Python's
344
- contextvars + asyncio primitives. The `LockManager` (runloop pause + semaphore coordination), `HandlerLock`
345
- (yield-and-reacquire), and `BusScopedEvent` proxy are the key differences that make the behavior match in practice.
848
+ - `kb/event` is peak RSS delta per event during active processing (most representative of OS-visible RAM in Activity Monitor / Task Manager, with `EventBus.max_history_size=1`)
849
+ - In `1 bus x 1 event x 50k parallel handlers` stats are shown per-handler for clarity, `0.02ms/handler * 50k handlers ~= 1000ms` for the entire event
850
+ - Browser runtime does not expose memory usage directly, in practice memory performance in-browser is comparable to Node (they both use V8)
346
851
 
347
- ## Publishing to npm (pnpm)
852
+ <br/>
348
853
 
349
- Manual publish from your machine:
854
+ ---
350
855
 
351
- 1. `cd bubus-ts`
352
- 2. `pnpm install --frozen-lockfile`
353
- 3. `pnpm login --registry https://registry.npmjs.org`
354
- 4. `pnpm run release:check`
355
- 5. `pnpm run release:dry-run`
356
- 6. `pnpm publish --access public`
856
+ <br/>
357
857
 
358
- Auth setup once per machine:
858
+ ## 👾 Development
359
859
 
360
- - `pnpm login --registry https://registry.npmjs.org`
860
+ ```bash
861
+ git clone https://github.com/pirate/bbus bubus && cd bubus
361
862
 
362
- CI publish is also configured in `.github/workflows/publish-npm.yml` and expects `NPM_TOKEN` in repo secrets.
863
+ cd ./bubus-ts
864
+ pnpm install
865
+
866
+ prek install # install pre-commit hooks
867
+ prek run --all-files # run pre-commit hooks on all files manually
868
+
869
+ pnpm lint
870
+ pnpm test
871
+ ```