@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/LICENSE +21 -0
- package/README.md +566 -0
- package/package.json +46 -0
- package/src/adapter.ts +204 -0
- package/src/drivers/ai.ts +99 -0
- package/src/drivers/cache.ts +48 -0
- package/src/drivers/common.ts +29 -0
- package/src/drivers/database.ts +160 -0
- package/src/drivers/flags.ts +50 -0
- package/src/drivers/identity.ts +133 -0
- package/src/drivers/logs.ts +61 -0
- package/src/drivers/mail.ts +73 -0
- package/src/drivers/notifications.ts +64 -0
- package/src/drivers/payments.ts +107 -0
- package/src/drivers/queue.ts +87 -0
- package/src/drivers/ratelimit.ts +66 -0
- package/src/drivers/realtime.ts +63 -0
- package/src/drivers/search.ts +92 -0
- package/src/drivers/social.ts +63 -0
- package/src/drivers/storage.ts +78 -0
- package/src/drivers/tokens.ts +87 -0
- package/src/errors.ts +166 -0
- package/src/events.ts +185 -0
- package/src/index.ts +22 -0
- package/src/query.ts +86 -0
- package/src/runtime/config.ts +231 -0
- package/src/runtime/dispatcher.ts +335 -0
- package/src/runtime/errands.ts +148 -0
- package/src/runtime/gate.ts +96 -0
- package/src/runtime/index.ts +81 -0
- package/src/runtime/jobs.ts +126 -0
- package/src/runtime/kernel.ts +202 -0
- package/src/runtime/requests.ts +77 -0
- package/src/runtime/transaction.ts +45 -0
- package/src/runtime/wards.ts +362 -0
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
|
+
}
|