@avelonjs/core 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ export * from './adapter'
2
+ export * from './drivers/common'
3
+ export * from './drivers/ai'
4
+ export * from './drivers/database'
5
+ export * from './drivers/flags'
6
+ export * from './drivers/identity'
7
+ export * from './drivers/cache'
8
+ export * from './drivers/logs'
9
+ export * from './drivers/mail'
10
+ export * from './drivers/notifications'
11
+ export * from './drivers/payments'
12
+ export * from './drivers/queue'
13
+ export * from './drivers/ratelimit'
14
+ export * from './drivers/realtime'
15
+ export * from './drivers/search'
16
+ export * from './drivers/social'
17
+ export * from './drivers/storage'
18
+ export * from './drivers/tokens'
19
+ export * from './errors'
20
+ export * from './events'
21
+ export * from './query'
22
+ export * from './runtime'
package/src/query.ts ADDED
@@ -0,0 +1,86 @@
1
+ /** Comparison operators with portable SQL three-valued semantics. */
2
+ export type CompareOp = '=' | '!=' | '<' | '<=' | '>' | '>=' | 'like' | 'ilike'
3
+
4
+ /** A stable ordering term for a query or relation load. */
5
+ export interface OrderTerm {
6
+ /** Column to order by. */
7
+ column: string
8
+ /** Sort direction. */
9
+ direction: 'asc' | 'desc'
10
+ /** Explicit placement for null values. */
11
+ nulls?: 'first' | 'last'
12
+ }
13
+
14
+ /** A relation the database driver should load with the parent query. */
15
+ export interface RelationLoad {
16
+ /** Relation name declared on the model and used as the result key. */
17
+ relation: string
18
+ /** Cardinality of the declared relation. */
19
+ kind: 'belongsTo' | 'hasOne' | 'hasMany'
20
+ /** Related table resolved by the model layer. */
21
+ table: string
22
+ /** Column on the parent row. */
23
+ localKey: string
24
+ /** Column on the related table. */
25
+ foreignKey: string
26
+ /** Columns to return from the related table. */
27
+ select: string[] | '*'
28
+ /** Predicate applied to related rows, or null when unfiltered. */
29
+ where: Predicate | null
30
+ /** Stable ordering applied to related rows. */
31
+ order: OrderTerm[]
32
+ /** Maximum related rows to load per parent. */
33
+ limit?: number
34
+ /** Nested relation loads checked against the driver's maximum depth. */
35
+ relations: RelationLoad[]
36
+ }
37
+
38
+ /**
39
+ * Serializable query representation produced by the model layer and compiled by a database
40
+ * driver.
41
+ */
42
+ export interface QueryIR {
43
+ /** Table the operation targets. */
44
+ table: string
45
+ /**
46
+ * Operation to compile. Count mode matches `where` AND `ward`; `select`, `relations`, and `order`
47
+ * must be empty, while `limit` and `offset` must be absent.
48
+ */
49
+ mode: 'select' | 'count' | 'insert' | 'update' | 'delete' | 'upsert'
50
+ /** Columns to select. */
51
+ select: string[] | '*'
52
+ /** Predicates composed with AND. */
53
+ where: Predicate[]
54
+ /** Relations to eager load. */
55
+ relations: RelationLoad[]
56
+ /** Stable ordering terms applied in sequence. */
57
+ order: OrderTerm[]
58
+ /** Maximum number of rows to return. */
59
+ limit?: number
60
+ /** Number of rows to skip. */
61
+ offset?: number
62
+ /** Values used by insert, update, and upsert operations. */
63
+ values?: Record<string, unknown> | Record<string, unknown>[]
64
+ /** Columns returned after a write. */
65
+ returning?: string[] | '*'
66
+ /**
67
+ * Explicit conflict target and update columns required by upserts. `update: '*'` updates every
68
+ * supplied value column not named in `conflict.columns`.
69
+ */
70
+ conflict?: { columns: [string, ...string[]]; update: [string, ...string[]] | '*' }
71
+ /** Ward predicate injected by the ward compiler and ANDed with `where`. Never author-supplied. */
72
+ ward?: Predicate
73
+ }
74
+
75
+ /**
76
+ * Portable predicate tree. Empty AND is true, empty OR is false, and an empty IN list is false
77
+ * unless negated.
78
+ */
79
+ export type Predicate =
80
+ | { kind: 'const'; value: boolean }
81
+ | { kind: 'compare'; column: string; op: CompareOp; value: unknown }
82
+ | { kind: 'null'; column: string; negated: boolean }
83
+ | { kind: 'in'; column: string; values: unknown[]; negated: boolean }
84
+ | { kind: 'and'; predicates: Predicate[] }
85
+ | { kind: 'or'; predicates: Predicate[] }
86
+ | { kind: 'not'; predicate: Predicate }
@@ -0,0 +1,231 @@
1
+ import type { Adapter, RequestCookies } from '../adapter'
2
+ import type { DatabaseDriver } from '../drivers/database'
3
+ import type { IdentityDriver } from '../drivers/identity'
4
+ import type { MailDriver } from '../drivers/mail'
5
+ import type { QueueDriver } from '../drivers/queue'
6
+ import type { StorageDriver } from '../drivers/storage'
7
+ import { Invalid, Unavailable } from '../errors'
8
+
9
+ /** Named driver registry resolved by facades such as `DB.connection` and `Mail.mailer`. */
10
+ export interface NamedDrivers<TDriver> {
11
+ /** Name of the default instance. Unknown names never fall back to this value. */
12
+ readonly default: string
13
+ /** Drivers keyed by instance name. Also accepts `connections`, `mailers`, or `disks`. */
14
+ readonly instances?: Readonly<Record<string, TDriver>>
15
+ /** Database named-connection map. */
16
+ readonly connections?: Readonly<Record<string, TDriver>>
17
+ /** Mail named-mailer map. */
18
+ readonly mailers?: Readonly<Record<string, TDriver>>
19
+ /** Storage named-disk map. */
20
+ readonly disks?: Readonly<Record<string, TDriver>>
21
+ }
22
+
23
+ /** A single driver or a named registry of drivers. */
24
+ export type MaybeNamed<TDriver> = TDriver | NamedDrivers<TDriver>
25
+
26
+ /** Driver wiring accepted by {@link defineConfig}. Every contract is optional until configured. */
27
+ export interface AvelonDrivers {
28
+ database?: MaybeNamed<DatabaseDriver>
29
+ identity?: (cookies: RequestCookies) => IdentityDriver
30
+ mail?: MaybeNamed<MailDriver>
31
+ storage?: MaybeNamed<StorageDriver>
32
+ queue?: MaybeNamed<QueueDriver>
33
+ }
34
+
35
+ /** Application configuration accepted by the service registry. */
36
+ export interface AvelonConfig {
37
+ /** Application name used in diagnostics. */
38
+ name?: string
39
+ /** Optional adapter. Wave C mounts routes; Wave B only stores the reference. */
40
+ adapter?: Adapter
41
+ /** Driver wiring for the current environment. */
42
+ drivers: AvelonDrivers
43
+ }
44
+
45
+ let activeConfig: AvelonConfig | undefined
46
+
47
+ function isNamedDrivers<TDriver>(value: MaybeNamed<TDriver>): value is NamedDrivers<TDriver> {
48
+ return (
49
+ typeof value === 'object' &&
50
+ value !== null &&
51
+ 'default' in value &&
52
+ typeof Reflect.get(value, 'default') === 'string' &&
53
+ (Reflect.has(value, 'instances') ||
54
+ Reflect.has(value, 'connections') ||
55
+ Reflect.has(value, 'mailers') ||
56
+ Reflect.has(value, 'disks'))
57
+ )
58
+ }
59
+
60
+ function namedMap<TDriver>(registry: NamedDrivers<TDriver>): Readonly<Record<string, TDriver>> {
61
+ return registry.instances ?? registry.connections ?? registry.mailers ?? registry.disks ?? {}
62
+ }
63
+
64
+ /**
65
+ * Resolves a named driver. An unknown name is `Invalid`; it never silently uses the default.
66
+ */
67
+ export function resolveNamedDriver<TDriver extends object>(
68
+ value: MaybeNamed<TDriver> | undefined,
69
+ name: string | undefined,
70
+ kind: string,
71
+ ): TDriver {
72
+ if (value === undefined) {
73
+ throw new Unavailable(`${kind} is not configured.`, {
74
+ metadata: { service: kind },
75
+ })
76
+ }
77
+ if (!isNamedDrivers(value)) {
78
+ const instance = Reflect.get(value, 'instance')
79
+ const resolvedName = name ?? (typeof instance === 'string' ? instance : 'default')
80
+ if (typeof instance === 'string' && instance !== resolvedName) {
81
+ throw new Invalid(`Unknown ${kind} '${resolvedName}'.`, {
82
+ metadata: { fields: { [kind]: [`${resolvedName} is not a configured ${kind}.`] } },
83
+ })
84
+ }
85
+ if (name !== undefined && name !== 'default' && instance !== name) {
86
+ throw new Invalid(`Unknown ${kind} '${name}'.`, {
87
+ metadata: { fields: { [kind]: [`${name} is not a configured ${kind}.`] } },
88
+ })
89
+ }
90
+ return value
91
+ }
92
+ const map = namedMap(value)
93
+ const key = name ?? value.default
94
+ const driver = map[key]
95
+ if (driver === undefined) {
96
+ throw new Invalid(`Unknown ${kind} '${key}'.`, {
97
+ metadata: { fields: { [kind]: [`${key} is not a configured ${kind}.`] } },
98
+ })
99
+ }
100
+ return driver
101
+ }
102
+
103
+ function mergeConfig(base: AvelonConfig, overlay: Partial<AvelonConfig>): AvelonConfig {
104
+ return {
105
+ ...base,
106
+ ...overlay,
107
+ drivers: {
108
+ ...base.drivers,
109
+ ...overlay.drivers,
110
+ },
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Registers process-wide configuration. The optional overlay is merged on top so per-environment
116
+ * files can replace driver wiring without copying the whole config.
117
+ */
118
+ export function defineConfig<T extends AvelonConfig>(
119
+ config: T,
120
+ environment: Partial<AvelonConfig> = {},
121
+ ): T {
122
+ activeConfig = mergeConfig(config, environment)
123
+ return activeConfig as T
124
+ }
125
+
126
+ /** Returns the active configuration or throws when wiring is missing. */
127
+ export function getConfig(): AvelonConfig {
128
+ if (activeConfig === undefined) {
129
+ throw new Unavailable('Avelon config has not been defined.', {
130
+ metadata: { service: 'config' },
131
+ })
132
+ }
133
+ return activeConfig
134
+ }
135
+
136
+ /** Clears config state. Intended for tests. */
137
+ export function resetConfig(): void {
138
+ activeConfig = undefined
139
+ }
140
+
141
+ /** Database facade bound to the configured driver, including named connections. */
142
+ export const DB = {
143
+ /** Returns the configured database driver for `name`, or the default connection. */
144
+ connection(name?: string): DatabaseDriver {
145
+ return resolveNamedDriver(getConfig().drivers.database, name, 'connection')
146
+ },
147
+ /** Default database driver. */
148
+ get driver(): DatabaseDriver {
149
+ return DB.connection()
150
+ },
151
+ execute: (...args: Parameters<DatabaseDriver['execute']>) => DB.connection().execute(...args),
152
+ rpc: (...args: Parameters<DatabaseDriver['rpc']>) => DB.connection().rpc(...args),
153
+ plan: () => DB.connection().plan(),
154
+ apply: () => DB.connection().apply(),
155
+ rollback: (steps?: number) => DB.connection().rollback(steps),
156
+ status: () => DB.connection().status(),
157
+ // bailiff-disable-next-line no-raw-outside-drivers -- facade passthrough; app raw() stays banned
158
+ raw: () => DB.connection().raw(),
159
+ }
160
+
161
+ /** Mail facade that resolves named mailers without falling back on unknown names. */
162
+ export const mailers = {
163
+ /** Returns the configured mailer for `name`, or the default mailer. */
164
+ mailer(name?: string): MailDriver {
165
+ return resolveNamedDriver(getConfig().drivers.mail, name, 'mailer')
166
+ },
167
+ }
168
+
169
+ /** Storage facade that resolves named disks without falling back on unknown names. */
170
+ export const disks = {
171
+ /** Returns the configured disk for `name`, or the default disk. */
172
+ disk(name?: string): StorageDriver {
173
+ return resolveNamedDriver(getConfig().drivers.storage, name, 'disk')
174
+ },
175
+ }
176
+
177
+ /** Queue facade that resolves named connections without falling back on unknown names. */
178
+ export const queues = {
179
+ /** Returns the configured queue connection for `name`, or the default connection. */
180
+ connection(name?: string): QueueDriver {
181
+ return resolveNamedDriver(getConfig().drivers.queue, name, 'queue')
182
+ },
183
+ }
184
+
185
+ /**
186
+ * Creates the request-scoped identity driver from configured wiring.
187
+ *
188
+ * Named `auth` rather than `Auth` so it does not collide with the `Auth` type alias exported from
189
+ * the identity contract.
190
+ */
191
+ export function auth(cookies: RequestCookies): IdentityDriver {
192
+ const factory = getConfig().drivers.identity
193
+ if (factory === undefined) {
194
+ throw new Unavailable('identity is not configured.', { metadata: { service: 'identity' } })
195
+ }
196
+ return factory(cookies)
197
+ }
198
+
199
+ /** Literal capability matrix for every configured driver instance. */
200
+ export function capabilityMatrix(
201
+ config: AvelonConfig = getConfig(),
202
+ ): Readonly<Record<string, Readonly<Record<string, unknown>>>> {
203
+ const matrix: Record<string, Record<string, unknown>> = {}
204
+
205
+ const collect = (contract: string, value: MaybeNamed<DriverLike> | undefined): void => {
206
+ if (value === undefined) return
207
+ if (!isNamedDrivers(value)) {
208
+ matrix[contract] = {
209
+ [value.instance]: { name: value.name, capabilities: value.capabilities },
210
+ }
211
+ return
212
+ }
213
+ const instances: Record<string, unknown> = {}
214
+ for (const [key, driver] of Object.entries(namedMap(value))) {
215
+ instances[key] = { name: driver.name, capabilities: driver.capabilities }
216
+ }
217
+ matrix[contract] = { default: value.default, instances }
218
+ }
219
+
220
+ collect('database', config.drivers.database)
221
+ collect('mail', config.drivers.mail)
222
+ collect('storage', config.drivers.storage)
223
+ collect('queue', config.drivers.queue)
224
+ return matrix
225
+ }
226
+
227
+ interface DriverLike {
228
+ readonly name: string
229
+ readonly instance: string
230
+ readonly capabilities: object
231
+ }
@@ -0,0 +1,335 @@
1
+ import type { QueueDriver } from '../drivers/queue'
2
+ import {
3
+ Event,
4
+ type EventConstructor,
5
+ type EventDispatchOptions,
6
+ type EventDispatcher,
7
+ type EventSerializer,
8
+ type EventSubscriber,
9
+ type ListenerDefinition,
10
+ type ListenerResult,
11
+ type QueuedEventEnvelope,
12
+ type SerializedEvent,
13
+ } from '../events'
14
+ import { backoffSeconds, jobBoard } from './jobs'
15
+ import { holdUntilCommit, isInTransaction } from './transaction'
16
+
17
+ interface StoredRegistration {
18
+ event?: EventConstructor
19
+ pattern?: string
20
+ listener: ListenerDefinition<Event>
21
+ }
22
+
23
+ class JsonEventSerializer implements EventSerializer {
24
+ readonly #constructors = new Map<string, EventConstructor>()
25
+
26
+ register(event: EventConstructor): void {
27
+ this.#constructors.set(event.name, event)
28
+ }
29
+
30
+ async serialize(event: Event): Promise<SerializedEvent> {
31
+ const payload: Record<string, unknown> = {}
32
+ for (const [key, value] of Object.entries(event as unknown as Record<string, unknown>)) {
33
+ payload[key] = value
34
+ }
35
+ return { name: event.constructor.name, payload, references: [] }
36
+ }
37
+
38
+ async deserialize(serialized: SerializedEvent): Promise<Event> {
39
+ const ctor = this.#constructors.get(serialized.name)
40
+ const event = ctor === undefined ? new Event() : Object.assign(new Event(), serialized.payload)
41
+ if (ctor !== undefined) Object.setPrototypeOf(event, ctor.prototype)
42
+ return event
43
+ }
44
+ }
45
+
46
+ function matchesPattern(pattern: string, name: string): boolean {
47
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*')
48
+ return new RegExp(`^${escaped}$`).test(name)
49
+ }
50
+
51
+ function isFalse(result: ListenerResult): result is false {
52
+ return result === false
53
+ }
54
+
55
+ /** In-process event dispatcher implementing the frozen `EventDispatcher` contract. */
56
+ export class InProcessEventDispatcher implements EventDispatcher {
57
+ readonly #registrations: StoredRegistration[] = []
58
+ readonly #after: Array<() => Promise<void>> = []
59
+ readonly #serializer: JsonEventSerializer
60
+ #queue: QueueDriver | undefined
61
+
62
+ constructor(options: { queue?: QueueDriver; serializer?: EventSerializer } = {}) {
63
+ this.#queue = options.queue
64
+ this.#serializer = new JsonEventSerializer()
65
+ }
66
+
67
+ /** Wires a queue driver for `delivery: 'queued'` listeners. Omitting `queue` uses the memory board. */
68
+ bindQueue(queue?: QueueDriver): void {
69
+ this.#queue = queue
70
+ }
71
+
72
+ listen<TEvent extends Event>(
73
+ event: EventConstructor<TEvent> | string,
74
+ listeners:
75
+ | ListenerDefinition<TEvent>
76
+ | readonly ListenerDefinition<TEvent>[]
77
+ | ListenerDefinition<Event>,
78
+ ): void {
79
+ if (typeof event === 'string') {
80
+ this.#registrations.push({
81
+ pattern: event,
82
+ listener: listeners as ListenerDefinition<Event>,
83
+ })
84
+ return
85
+ }
86
+ this.#serializer.register(event)
87
+ const list = Array.isArray(listeners) ? listeners : [listeners]
88
+ for (const listener of list) {
89
+ this.#registrations.push({
90
+ event,
91
+ listener: listener as ListenerDefinition<Event>,
92
+ })
93
+ }
94
+ }
95
+
96
+ subscribe(subscriber: EventSubscriber): void {
97
+ for (const registration of subscriber.listeners()) {
98
+ this.listen(registration.event, registration.listener)
99
+ }
100
+ }
101
+
102
+ async dispatch<TEvent extends Event>(
103
+ event: TEvent,
104
+ options: EventDispatchOptions = {},
105
+ ): Promise<void> {
106
+ if (options.afterCommit && isInTransaction()) {
107
+ const captured = event
108
+ holdUntilCommit(() => this.#dispatchNow(captured, false))
109
+ return
110
+ }
111
+ await this.#dispatchNow(event, false)
112
+ }
113
+
114
+ async dispatchAfterResponse<TEvent extends Event>(event: TEvent): Promise<void> {
115
+ await this.#dispatchNow(event, true)
116
+ }
117
+
118
+ async until<TResult, TEvent extends Event>(
119
+ event: TEvent,
120
+ options: EventDispatchOptions = {},
121
+ ): Promise<TResult | null> {
122
+ if (options.afterCommit && isInTransaction()) {
123
+ return new Promise<TResult | null>((resolve, reject) => {
124
+ holdUntilCommit(async () => {
125
+ try {
126
+ resolve(await this.#untilNow<TResult>(event))
127
+ } catch (error) {
128
+ reject(error)
129
+ }
130
+ })
131
+ })
132
+ }
133
+ return this.#untilNow<TResult>(event)
134
+ }
135
+
136
+ /** Runs listeners scheduled for post-response delivery. */
137
+ async flushAfterResponse(): Promise<void> {
138
+ const pending = this.#after.splice(0, this.#after.length)
139
+ for (const job of pending) await job()
140
+ }
141
+
142
+ /** Drains queued listeners from the wired queue driver, or the process-local job board. */
143
+ async flushQueued(now = Date.now()): Promise<number> {
144
+ if (this.#queue !== undefined) {
145
+ return this.#queue.drain(async (receipt) => {
146
+ await this.#deliverQueued(receipt)
147
+ })
148
+ }
149
+ const receipts = jobBoard().take({ now, name: 'avelon.listener' })
150
+ let handled = 0
151
+ for (const receipt of receipts) {
152
+ if (receipt.job.name !== 'avelon.listener') continue
153
+ try {
154
+ await this.#deliverQueued(receipt)
155
+ handled += 1
156
+ } catch (error) {
157
+ const envelope = queuedEnvelope(receipt.job.payload)
158
+ if (envelope !== undefined && receipt.attempt < envelope.tries) {
159
+ jobBoard().enqueue(receipt.job, {
160
+ attempt: receipt.attempt,
161
+ availableAt: now + backoffSeconds(envelope.backoff, receipt.attempt) * 1000,
162
+ tries: envelope.tries,
163
+ backoff: envelope.backoff,
164
+ })
165
+ } else {
166
+ jobBoard().fail(receipt, error)
167
+ }
168
+ }
169
+ }
170
+ return handled
171
+ }
172
+
173
+ /** Clears registrations, the wired queue, and scheduled work. Intended for tests. */
174
+ reset(): void {
175
+ this.#registrations.length = 0
176
+ this.#after.length = 0
177
+ this.#queue = undefined
178
+ jobBoard().flush()
179
+ }
180
+
181
+ #matching(event: Event): StoredRegistration[] {
182
+ return this.#registrations
183
+ .filter((entry) => {
184
+ if (entry.event !== undefined) return event instanceof entry.event
185
+ if (entry.pattern !== undefined)
186
+ return matchesPattern(entry.pattern, event.constructor.name)
187
+ return false
188
+ })
189
+ .sort((left, right) => (left.listener.priority ?? 0) - (right.listener.priority ?? 0))
190
+ }
191
+
192
+ async #dispatchNow(event: Event, forceAfter: boolean): Promise<void> {
193
+ for (const entry of this.#matching(event)) {
194
+ if (event.isPropagationStopped()) return
195
+ const delivery = forceAfter ? 'after' : (entry.listener.delivery ?? 'sync')
196
+ if (delivery === 'sync') {
197
+ const result = await entry.listener.handle(event, { delivery: 'sync' })
198
+ if (isFalse(result)) {
199
+ event.stopPropagation()
200
+ return
201
+ }
202
+ continue
203
+ }
204
+ if (delivery === 'after') {
205
+ const captured = event
206
+ const listener = entry.listener
207
+ this.#after.push(async () => {
208
+ if (captured.isPropagationStopped()) return
209
+ const result = await listener.handle(captured, { delivery: 'after' })
210
+ if (isFalse(result)) captured.stopPropagation()
211
+ })
212
+ continue
213
+ }
214
+ await this.#enqueueQueued(event, entry)
215
+ }
216
+ }
217
+
218
+ async #untilNow<TResult>(event: Event): Promise<TResult | null> {
219
+ for (const entry of this.#matching(event)) {
220
+ if (event.isPropagationStopped()) return null
221
+ const delivery = entry.listener.delivery ?? 'sync'
222
+ if (delivery !== 'sync') continue
223
+ const result = await entry.listener.handle(event, { delivery: 'sync' })
224
+ if (isFalse(result)) {
225
+ event.stopPropagation()
226
+ return null
227
+ }
228
+ if (result !== undefined && result !== null) {
229
+ event.stopPropagation()
230
+ return result as TResult
231
+ }
232
+ }
233
+ return null
234
+ }
235
+
236
+ async #deliverQueued(receipt: {
237
+ job: { name: string; payload: unknown }
238
+ attempt: number
239
+ }): Promise<void> {
240
+ if (receipt.job.name !== 'avelon.listener') return
241
+ const envelope = queuedEnvelope(receipt.job.payload)
242
+ if (envelope === undefined) return
243
+ const event = await this.#serializer.deserialize(envelope.event)
244
+ const registration = this.#registrations.find(
245
+ (entry) =>
246
+ entry.listener.delivery === 'queued' &&
247
+ entry.listener.queue === envelope.queue &&
248
+ (entry.event?.name === envelope.eventName ||
249
+ (entry.pattern !== undefined && matchesPattern(entry.pattern, envelope.eventName))),
250
+ )
251
+ if (registration === undefined || registration.listener.delivery !== 'queued') return
252
+ await registration.listener.handle(event, {
253
+ delivery: 'queued',
254
+ queue: envelope.queue,
255
+ attempt: receipt.attempt,
256
+ tries: envelope.tries,
257
+ })
258
+ }
259
+
260
+ async #enqueueQueued(event: Event, entry: StoredRegistration): Promise<void> {
261
+ if (entry.listener.delivery !== 'queued') return
262
+ const listener = entry.listener
263
+ const serialized = await this.#serializer.serialize(event)
264
+ const queue = listener.queue ?? 'default'
265
+ const tries = listener.tries ?? 1
266
+ const backoff = listener.backoff ?? 0
267
+ const envelope: QueuedEventEnvelope & {
268
+ tries: number
269
+ backoff: number | readonly number[]
270
+ eventName: string
271
+ } = {
272
+ event: serialized,
273
+ listener: entry.event?.name ?? entry.pattern ?? event.constructor.name,
274
+ queue,
275
+ attempt: 0,
276
+ availableAt: Date.now(),
277
+ tries,
278
+ backoff,
279
+ eventName: event.constructor.name,
280
+ }
281
+ const job = { name: 'avelon.listener', payload: envelope, queue }
282
+ if (this.#queue !== undefined) {
283
+ await this.#queue.enqueue(job)
284
+ return
285
+ }
286
+ jobBoard().enqueue(job, { tries, backoff })
287
+ }
288
+ }
289
+
290
+ type QueuedEnvelope = QueuedEventEnvelope & {
291
+ tries: number
292
+ backoff: number | readonly number[]
293
+ eventName: string
294
+ }
295
+
296
+ function queuedEnvelope(payload: unknown): QueuedEnvelope | undefined {
297
+ if (typeof payload !== 'object' || payload === null) return undefined
298
+ if (!('event' in payload) || !('queue' in payload) || !('eventName' in payload)) return undefined
299
+ const envelope = payload as QueuedEnvelope
300
+ if (typeof envelope.eventName !== 'string' || typeof envelope.queue !== 'string') return undefined
301
+ return envelope
302
+ }
303
+
304
+ const defaultDispatcher = new InProcessEventDispatcher()
305
+
306
+ /** Process-wide dispatcher used by the `Events` facade. */
307
+ export function eventDispatcher(): InProcessEventDispatcher {
308
+ return defaultDispatcher
309
+ }
310
+
311
+ /** Creates an isolated dispatcher, used when a test or worker needs its own bus. */
312
+ export function createEventDispatcher(options?: {
313
+ queue?: QueueDriver
314
+ serializer?: EventSerializer
315
+ }): InProcessEventDispatcher {
316
+ return new InProcessEventDispatcher(options)
317
+ }
318
+
319
+ /** Application-facing event bus. The frozen `Event` class is unchanged. */
320
+ export const Events: EventDispatcher & {
321
+ flushAfterResponse: () => Promise<void>
322
+ flushQueued: (now?: number) => Promise<number>
323
+ bindQueue: (queue?: QueueDriver) => void
324
+ reset: () => void
325
+ } = {
326
+ listen: defaultDispatcher.listen.bind(defaultDispatcher),
327
+ subscribe: defaultDispatcher.subscribe.bind(defaultDispatcher),
328
+ dispatch: defaultDispatcher.dispatch.bind(defaultDispatcher),
329
+ dispatchAfterResponse: defaultDispatcher.dispatchAfterResponse.bind(defaultDispatcher),
330
+ until: defaultDispatcher.until.bind(defaultDispatcher),
331
+ flushAfterResponse: () => defaultDispatcher.flushAfterResponse(),
332
+ flushQueued: (now) => defaultDispatcher.flushQueued(now),
333
+ bindQueue: (queue) => defaultDispatcher.bindQueue(queue),
334
+ reset: () => defaultDispatcher.reset(),
335
+ }