Refactor event bus and queue system; detect cycles in DI realization and make
continuous-integration/drone/push Build is failing
Details
continuous-integration/drone/push Build is failing
Details
parent
506fb55c74
commit
6d1cf18680
@ -1,27 +1,12 @@
|
|||||||
import {Event} from '../../event/Event'
|
|
||||||
import {SecurityContext} from '../context/SecurityContext'
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
import {Awaitable, JSONState} from '../../util'
|
|
||||||
import {Authenticatable} from '../types'
|
import {Authenticatable} from '../types'
|
||||||
|
import {BaseEvent} from '../../support/bus'
|
||||||
|
|
||||||
/**
|
export abstract class AuthenticationEvent extends BaseEvent {
|
||||||
* Event fired when a user is authenticated.
|
|
||||||
*/
|
|
||||||
export class AuthenticationEvent extends Event {
|
|
||||||
constructor(
|
constructor(
|
||||||
public readonly user: Authenticatable,
|
public readonly user: Authenticatable,
|
||||||
public readonly context: SecurityContext,
|
public readonly context: SecurityContext,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
async dehydrate(): Promise<JSONState> {
|
|
||||||
return {
|
|
||||||
user: await this.user.dehydrate(),
|
|
||||||
contextName: this.context.name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
||||||
// TODO fill this in
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,54 @@
|
|||||||
|
import {BaseSerializer, ObjectSerializer, SerialPayload} from '../../support/bus'
|
||||||
|
import {AuthenticationEvent} from '../event/AuthenticationEvent'
|
||||||
|
import {ErrorWithContext, JSONState} from '../../util'
|
||||||
|
import {Authenticatable} from '../types'
|
||||||
|
import {StaticInstantiable} from '../../di'
|
||||||
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
|
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
|
||||||
|
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
|
||||||
|
import {UserFlushedEvent} from '../event/UserFlushedEvent'
|
||||||
|
|
||||||
|
export interface AuthenticationEventSerialPayload extends JSONState {
|
||||||
|
user: SerialPayload<Authenticatable, JSONState>
|
||||||
|
eventName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectSerializer()
|
||||||
|
export class AuthenticationEventSerializer extends BaseSerializer<AuthenticationEvent, AuthenticationEventSerialPayload> {
|
||||||
|
protected async decodeSerial(serial: AuthenticationEventSerialPayload): Promise<AuthenticationEvent> {
|
||||||
|
const user = await this.getSerialization().decode(serial.user)
|
||||||
|
const context = await this.getRequest().make(SecurityContext)
|
||||||
|
|
||||||
|
const EventClass = this.getEventClass(serial.eventName)
|
||||||
|
return new EventClass(user, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async encodeActual(actual: AuthenticationEvent): Promise<AuthenticationEventSerialPayload> {
|
||||||
|
return {
|
||||||
|
eventName: actual.eventName,
|
||||||
|
user: await this.getSerialization().encode(actual.user),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getName(): string {
|
||||||
|
return '@extollo/lib:AuthenticationEventSerializer'
|
||||||
|
}
|
||||||
|
|
||||||
|
matchActual(some: AuthenticationEvent): boolean {
|
||||||
|
return some instanceof AuthenticationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getEventClass(name: string): StaticInstantiable<AuthenticationEvent> {
|
||||||
|
if ( name === '@extollo/lib:UserAuthenticatedEvent' ) {
|
||||||
|
return UserAuthenticatedEvent
|
||||||
|
} else if ( name === '@extollo/lib:UserAuthenticationResumedEvent' ) {
|
||||||
|
return UserAuthenticationResumedEvent
|
||||||
|
} else if ( name === '@extollo/lib:UserFlushedEvent' ) {
|
||||||
|
return UserFlushedEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ErrorWithContext('Unable to map event name to AuthenticationEvent implementation', {
|
||||||
|
eventName: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../../Directive'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {Queue} from '../../../support/bus'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly queue!: Queue
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'pop a single item from the queue and execute it'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return 'queue-work'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queueable = await this.queue.pop()
|
||||||
|
if ( !queueable ) {
|
||||||
|
this.info('There are no items in the queue.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.info(`Fetched 1 item from the queue`)
|
||||||
|
await queueable.execute()
|
||||||
|
this.success('Executed 1 item from the queue')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
this.error('Failed to execute queueable:')
|
||||||
|
this.error(e)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +0,0 @@
|
|||||||
import {Dispatchable} from './types'
|
|
||||||
import {Awaitable, JSONState} from '../util'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Abstract class representing an event that may be fired.
|
|
||||||
*/
|
|
||||||
export abstract class Event implements Dispatchable {
|
|
||||||
|
|
||||||
|
|
||||||
abstract dehydrate(): Awaitable<JSONState>
|
|
||||||
|
|
||||||
abstract rehydrate(state: JSONState): Awaitable<void>
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
import {Instantiable, Singleton, StaticClass} from '../di'
|
|
||||||
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
|
|
||||||
import {Awaitable, Collection, uuid4} from '../util'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
|
||||||
*/
|
|
||||||
@Singleton()
|
|
||||||
export class EventBus implements Bus {
|
|
||||||
/**
|
|
||||||
* Collection of subscribers, by their events.
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
|
|
||||||
|
|
||||||
subscribe<T extends Dispatchable>(event: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
|
|
||||||
const entry: EventSubscriberEntry<T> = {
|
|
||||||
id: uuid4(),
|
|
||||||
event,
|
|
||||||
subscriber,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscribers.push(entry)
|
|
||||||
return this.buildSubscription(entry.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void> {
|
|
||||||
this.subscribers = this.subscribers.where('subscriber', '!=', subscriber)
|
|
||||||
}
|
|
||||||
|
|
||||||
async dispatch(event: Dispatchable): Promise<void> {
|
|
||||||
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
|
|
||||||
await this.subscribers.where('event', '=', eventClass)
|
|
||||||
.promiseMap(entry => entry.subscriber(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an EventSubscription object for the subscriber of the given ID.
|
|
||||||
* @param id
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
protected buildSubscription(id: string): EventSubscription {
|
|
||||||
let subscribed = true
|
|
||||||
return {
|
|
||||||
unsubscribe: (): Awaitable<void> => {
|
|
||||||
if ( subscribed ) {
|
|
||||||
this.subscribers = this.subscribers.where('id', '!=', id)
|
|
||||||
subscribed = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import {EventBus} from './EventBus'
|
|
||||||
import {Collection} from '../util'
|
|
||||||
import {Bus, Dispatchable} from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A non-queued bus implementation that executes subscribers immediately in the main thread.
|
|
||||||
* This bus also supports "propagating" events along to any other connected buses.
|
|
||||||
* Such behavior is useful, e.g., if we want to have a semi-isolated request-
|
|
||||||
* level bus whose events still reach the global EventBus instance.
|
|
||||||
*/
|
|
||||||
export class PropagatingEventBus extends EventBus {
|
|
||||||
protected recipients: Collection<Bus> = new Collection<Bus>()
|
|
||||||
|
|
||||||
async dispatch(event: Dispatchable): Promise<void> {
|
|
||||||
await super.dispatch(event)
|
|
||||||
await this.recipients.promiseMap(bus => bus.dispatch(event))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the given bus to receive events fired on this bus.
|
|
||||||
* @param recipient
|
|
||||||
*/
|
|
||||||
connect(recipient: Bus): void {
|
|
||||||
if ( !this.recipients.includes(recipient) ) {
|
|
||||||
this.recipients.push(recipient)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
import {Awaitable, Rehydratable} from '../util'
|
|
||||||
import {Instantiable, StaticClass} from '../di'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A closure that should be executed with the given event is fired.
|
|
||||||
*/
|
|
||||||
export type EventSubscriber<T extends Dispatchable> = (event: T) => Awaitable<void>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object used to track event subscriptions internally.
|
|
||||||
*/
|
|
||||||
export interface EventSubscriberEntry<T extends Dispatchable> {
|
|
||||||
/** Globally unique ID of this subscription. */
|
|
||||||
id: string
|
|
||||||
|
|
||||||
/** The event class subscribed to. */
|
|
||||||
event: StaticClass<T, Instantiable<T>>
|
|
||||||
|
|
||||||
/** The closure to execute when the event is fired. */
|
|
||||||
subscriber: EventSubscriber<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object returned upon subscription, used to unsubscribe.
|
|
||||||
*/
|
|
||||||
export interface EventSubscription {
|
|
||||||
/**
|
|
||||||
* Unsubscribe the associated listener from the event bus.
|
|
||||||
*/
|
|
||||||
unsubscribe(): Awaitable<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An instance of something that can be fired on an event bus.
|
|
||||||
*/
|
|
||||||
export interface Dispatchable extends Rehydratable {
|
|
||||||
shouldQueue?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An event-driven bus that manages subscribers and dispatched items.
|
|
||||||
*/
|
|
||||||
export interface Bus {
|
|
||||||
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
|
|
||||||
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
|
|
||||||
dispatch(event: Dispatchable): Awaitable<void>
|
|
||||||
}
|
|
@ -0,0 +1,28 @@
|
|||||||
|
import {HTTPKernelModule} from '../HTTPKernelModule'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {HTTPKernel} from '../HTTPKernel'
|
||||||
|
import {Request} from '../../lifecycle/Request'
|
||||||
|
import {Bus} from '../../../support/bus'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP kernel module that creates a request-specific event bus
|
||||||
|
* and injects it into the request container.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ClearRequestEventBusHTTPModule extends HTTPKernelModule {
|
||||||
|
@Inject()
|
||||||
|
protected bus!: Bus
|
||||||
|
|
||||||
|
public static register(kernel: HTTPKernel): void {
|
||||||
|
kernel.register(this).first()
|
||||||
|
}
|
||||||
|
|
||||||
|
public async apply(request: Request): Promise<Request> {
|
||||||
|
const requestBus = request.make<Bus>(Bus)
|
||||||
|
await requestBus.down()
|
||||||
|
|
||||||
|
// FIXME disconnect request bus from global event bus
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
@ -1,67 +1,17 @@
|
|||||||
import {Event} from '../../../event/Event'
|
|
||||||
import {Inject, Injectable} from '../../../di'
|
|
||||||
import {InvalidJSONStateError, JSONState} from '../../../util'
|
|
||||||
import {Connection} from '../Connection'
|
import {Connection} from '../Connection'
|
||||||
import {DatabaseService} from '../../DatabaseService'
|
import {BaseEvent} from '../../../support/bus'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event fired when a query is executed.
|
* Event fired when a query is executed.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
export class QueryExecutedEvent extends BaseEvent {
|
||||||
export class QueryExecutedEvent extends Event {
|
|
||||||
@Inject()
|
|
||||||
protected database!: DatabaseService
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the connection where the query was executed.
|
|
||||||
* @protected
|
|
||||||
*/
|
|
||||||
public connectionName!: string
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The connection where the query was executed.
|
|
||||||
*/
|
|
||||||
public connection!: Connection
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The query that was executed.
|
|
||||||
*/
|
|
||||||
public query!: string
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
connectionName?: string,
|
public readonly connectionName: string,
|
||||||
connection?: Connection,
|
public readonly connection: Connection,
|
||||||
query?: string,
|
public readonly query: string,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
if ( connectionName ) {
|
|
||||||
this.connectionName = connectionName
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( connection ) {
|
|
||||||
this.connection = connection
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( query ) {
|
|
||||||
this.query = query
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async dehydrate(): Promise<JSONState> {
|
|
||||||
return {
|
|
||||||
connectionName: this.connectionName,
|
|
||||||
query: this.query,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rehydrate(state: JSONState): void {
|
|
||||||
if ( !state.connectionName || !state.query ) {
|
|
||||||
throw new InvalidJSONStateError('Missing connectionName or query from QueryExecutedEvent state.')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.query = String(state.query)
|
|
||||||
this.connectionName = String(state.connectionName)
|
|
||||||
this.connection = this.database.get(this.connectionName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventName = '@extollo/lib.QueryExecutedEvent'
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import {BaseSerializer} from '../../../support/bus'
|
||||||
|
import {QueryExecutedEvent} from './QueryExecutedEvent'
|
||||||
|
import {Awaitable, JSONState} from '../../../util'
|
||||||
|
import {Container, Inject, Injectable} from '../../../di'
|
||||||
|
import {DatabaseService} from '../../DatabaseService'
|
||||||
|
import {ObjectSerializer} from '../../../support/bus/serial/decorators'
|
||||||
|
|
||||||
|
export interface QueryExecutedEventSerialPayload extends JSONState {
|
||||||
|
connectionName: string
|
||||||
|
query: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectSerializer()
|
||||||
|
@Injectable()
|
||||||
|
export class QueryExecutedEventSerializer extends BaseSerializer<QueryExecutedEvent, QueryExecutedEventSerialPayload> {
|
||||||
|
@Inject()
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
protected decodeSerial(serial: QueryExecutedEventSerialPayload): Awaitable<QueryExecutedEvent> {
|
||||||
|
const connection = this.injector.make<DatabaseService>(DatabaseService).get(serial.connectionName)
|
||||||
|
return new QueryExecutedEvent(serial.connectionName, connection, serial.query)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected encodeActual(actual: QueryExecutedEvent): Awaitable<QueryExecutedEventSerialPayload> {
|
||||||
|
return {
|
||||||
|
connectionName: actual.connectionName,
|
||||||
|
query: actual.query,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getName(): string {
|
||||||
|
return '@extollo/lib.QueryExecutedEventSerializer'
|
||||||
|
}
|
||||||
|
|
||||||
|
matchActual(some: QueryExecutedEvent): boolean {
|
||||||
|
return some instanceof QueryExecutedEvent
|
||||||
|
}
|
||||||
|
}
|
@ -1,49 +1,13 @@
|
|||||||
import {Event} from '../../../event/Event'
|
|
||||||
import {Migration} from '../Migration'
|
import {Migration} from '../Migration'
|
||||||
import {Inject, Injectable} from '../../../di'
|
import {BaseEvent} from '../../../support/bus'
|
||||||
import {Migrations} from '../../services/Migrations'
|
|
||||||
import {ErrorWithContext} from '../../../util'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic base-class for migration-related events.
|
* Generic base-class for migration-related events.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
export abstract class MigrationEvent extends BaseEvent {
|
||||||
export abstract class MigrationEvent extends Event {
|
|
||||||
@Inject()
|
|
||||||
protected readonly migrations!: Migrations
|
|
||||||
|
|
||||||
/** The migration relevant to this event. */
|
|
||||||
private internalMigration: Migration
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the relevant migration.
|
|
||||||
*/
|
|
||||||
public get migration(): Migration {
|
|
||||||
return this.internalMigration
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
migration: Migration,
|
public readonly migration: Migration,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
this.internalMigration = migration
|
|
||||||
}
|
|
||||||
|
|
||||||
dehydrate(): {identifier: string} {
|
|
||||||
return {
|
|
||||||
identifier: this.migration.identifier,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rehydrate(state: {identifier: string}): void {
|
|
||||||
const migration = this.migrations.get(state.identifier)
|
|
||||||
|
|
||||||
if ( !migration ) {
|
|
||||||
throw new ErrorWithContext(`Unable to find migration with identifier: ${state.identifier}`, {
|
|
||||||
identifier: state.identifier,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.internalMigration = migration
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,59 @@
|
|||||||
|
import {BaseSerializer} from '../../../support/bus'
|
||||||
|
import {ObjectSerializer} from '../../../support/bus/serial/decorators'
|
||||||
|
import {Injectable, Instantiable} from '../../../di'
|
||||||
|
import {Awaitable, ErrorWithContext, JSONState} from '../../../util'
|
||||||
|
import {MigrationEvent} from './MigrationEvent'
|
||||||
|
import {Migrations} from '../../services/Migrations'
|
||||||
|
import {AppliedMigrationEvent} from './AppliedMigrationEvent'
|
||||||
|
import {ApplyingMigrationEvent} from './ApplyingMigrationEvent'
|
||||||
|
import {RolledBackMigrationEvent} from './RolledBackMigrationEvent'
|
||||||
|
import {RollingBackMigrationEvent} from './RollingBackMigrationEvent'
|
||||||
|
|
||||||
|
export interface MigrationEventSerialPayload extends JSONState {
|
||||||
|
identifier: string
|
||||||
|
eventType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectSerializer()
|
||||||
|
@Injectable()
|
||||||
|
export class MigrationEventSerializer extends BaseSerializer<MigrationEvent, MigrationEventSerialPayload> {
|
||||||
|
protected decodeSerial(serial: MigrationEventSerialPayload): Awaitable<MigrationEvent> {
|
||||||
|
const migration = this.make<Migrations>(Migrations).get(serial.identifier)
|
||||||
|
if ( !migration ) {
|
||||||
|
throw new ErrorWithContext(`Invalid serialized migration identifier: ${serial.identifier}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new (this.stringToEvent(serial.eventType))(migration))
|
||||||
|
}
|
||||||
|
|
||||||
|
protected encodeActual(actual: MigrationEvent): Awaitable<MigrationEventSerialPayload> {
|
||||||
|
return {
|
||||||
|
identifier: actual.migration.identifier,
|
||||||
|
eventType: actual.eventName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getName(): string {
|
||||||
|
return '@extollo/lib.MigrationEventSerializer'
|
||||||
|
}
|
||||||
|
|
||||||
|
matchActual(some: MigrationEvent): boolean {
|
||||||
|
return some instanceof MigrationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringToEvent(name: string): Instantiable<MigrationEvent> {
|
||||||
|
if ( name === '@extollo/lib.AppliedMigrationEvent' ) {
|
||||||
|
return AppliedMigrationEvent
|
||||||
|
} else if ( name === '@extollo/lib.ApplyingMigrationEvent' ) {
|
||||||
|
return ApplyingMigrationEvent
|
||||||
|
} else if ( name === '@extollo/lib.RollingBackMigrationEvent' ) {
|
||||||
|
return RollingBackMigrationEvent
|
||||||
|
} else if ( name === '@extollo/lib.RolledBackMigrationEvent' ) {
|
||||||
|
return RolledBackMigrationEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ErrorWithContext(`Invalid migration event name: ${name}`, {
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -1,31 +1,19 @@
|
|||||||
import {Model} from '../Model'
|
import {Model} from '../Model'
|
||||||
import {Event} from '../../../event/Event'
|
import {BaseEvent} from '../../../support/bus'
|
||||||
import {JSONState} from '../../../util'
|
import {Awaitable} from '../../../util'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base class for events that concern an instance of a model.
|
* Base class for events that concern an instance of a model.
|
||||||
|
* @fixme support serialization
|
||||||
*/
|
*/
|
||||||
export abstract class ModelEvent<T extends Model<T>> extends Event {
|
export abstract class ModelEvent<T extends Model<T>> extends BaseEvent {
|
||||||
/**
|
|
||||||
* The instance of the model.
|
|
||||||
*/
|
|
||||||
public instance!: T
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
instance?: T,
|
public readonly instance: T,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
if ( instance ) {
|
|
||||||
this.instance = instance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO implement serialization here
|
|
||||||
dehydrate(): Promise<JSONState> {
|
|
||||||
return Promise.resolve({})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rehydrate(/* state: JSONState */): void | Promise<void> {
|
shouldBroadcast(): Awaitable<boolean> {
|
||||||
return undefined
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,15 @@
|
|||||||
import {CanonicalStatic} from './CanonicalStatic'
|
import {CanonicalStatic} from './CanonicalStatic'
|
||||||
import {Singleton, Instantiable, StaticClass} from '../di'
|
import {Singleton, Instantiable} from '../di'
|
||||||
import {CanonicalDefinition} from './Canonical'
|
import {Queueable} from '../support/bus'
|
||||||
import {Queueable} from '../support/queue/Queue'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A canonical unit that resolves Queueable classes from `app/queueables`.
|
* A canonical unit that resolves Queueable classes from `app/jobs`.
|
||||||
*/
|
*/
|
||||||
@Singleton()
|
@Singleton()
|
||||||
export class Queueables extends CanonicalStatic<Queueable, Instantiable<Queueable>> {
|
export class Queueables extends CanonicalStatic<Queueables, Instantiable<Queueable>> {
|
||||||
protected appPath = ['jobs']
|
protected appPath = ['jobs']
|
||||||
|
|
||||||
protected canonicalItem = 'job'
|
protected canonicalItem = 'job'
|
||||||
|
|
||||||
protected suffix = '.job.js'
|
protected suffix = '.job.js'
|
||||||
|
|
||||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<StaticClass<Queueable, Instantiable<Queueable>>> {
|
|
||||||
const item = await super.initCanonicalItem(definition)
|
|
||||||
if ( !(item.prototype instanceof Queueable) ) {
|
|
||||||
throw new TypeError(`Invalid middleware definition: ${definition.originalName}. Controllers must extend from @extollo/lib.Queueable.`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,189 @@
|
|||||||
|
import {Inject, Singleton, StaticInstantiable} from '../../di'
|
||||||
|
import {BusSubscriber, Event, EventBus, EventHandler, EventHandlerReturn, EventHandlerSubscription} from './types'
|
||||||
|
import {Awaitable, Collection, Pipeline, uuid4} from '../../util'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Unit} from '../../lifecycle/Unit'
|
||||||
|
|
||||||
|
export interface BusInternalSubscription {
|
||||||
|
busUuid: string
|
||||||
|
subscriberUuid: string
|
||||||
|
subscription: EventHandlerSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Propagating event bus implementation.
|
||||||
|
*/
|
||||||
|
@Singleton()
|
||||||
|
export class Bus<TEvent extends Event = Event> extends Unit implements EventBus<TEvent> {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
public readonly uuid = uuid4()
|
||||||
|
|
||||||
|
/** Local listeners subscribed to events on this bus. */
|
||||||
|
protected subscribers: Collection<BusSubscriber<Event>> = new Collection()
|
||||||
|
|
||||||
|
/** Connections to other event busses to be propagated. */
|
||||||
|
protected connectors: Collection<EventBus> = new Collection()
|
||||||
|
|
||||||
|
protected subscriptions: Collection<BusInternalSubscription> = new Collection()
|
||||||
|
|
||||||
|
/** True if the bus has been initialized. */
|
||||||
|
private isUp = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push an event onto the bus.
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
async push(event: TEvent): Promise<void> {
|
||||||
|
if ( event.originBusUuid === this.uuid ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !event.originBusUuid ) {
|
||||||
|
event.originBusUuid = this.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( await this.callSubscribers(event) ) {
|
||||||
|
// One of the subscribers halted propagation of the event
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( await this.shouldBroadcast(event) ) {
|
||||||
|
await this.connectors.awaitMapCall('push', event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the given event should be pushed to connected event busses. */
|
||||||
|
protected async shouldBroadcast(event: Event): Promise<boolean> {
|
||||||
|
if ( typeof event.shouldBroadcast === 'function' ) {
|
||||||
|
return event.shouldBroadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(event.shouldBroadcast)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call all local listeners for the given event. Returns true if the propagation
|
||||||
|
* of the event should be halted.
|
||||||
|
* @param event
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async callSubscribers(event: TEvent): Promise<boolean> {
|
||||||
|
return this.subscribers
|
||||||
|
.filter(sub => event instanceof sub.eventKey)
|
||||||
|
.pluck('handler')
|
||||||
|
.toAsync()
|
||||||
|
.some(handler => handler(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a pipeline as an event handler. */
|
||||||
|
pipe<T extends TEvent>(eventKey: StaticInstantiable<T>, line: Pipeline<T, EventHandlerReturn>): Awaitable<EventHandlerSubscription> {
|
||||||
|
return this.subscribe(eventKey, event => line.apply(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an event on the bus.
|
||||||
|
* @param eventKey
|
||||||
|
* @param handler
|
||||||
|
*/
|
||||||
|
async subscribe<T extends TEvent>(eventKey: StaticInstantiable<T>, handler: EventHandler<T>): Promise<EventHandlerSubscription> {
|
||||||
|
const uuid = uuid4()
|
||||||
|
|
||||||
|
this.subscribers.push({
|
||||||
|
eventName: eventKey.prototype.eventName, // FIXME this is not working
|
||||||
|
handler,
|
||||||
|
eventKey,
|
||||||
|
uuid,
|
||||||
|
} as unknown as BusSubscriber<Event>)
|
||||||
|
|
||||||
|
this.subscriptions.concat(await this.connectors
|
||||||
|
.promiseMap<BusInternalSubscription>(async bus => {
|
||||||
|
return {
|
||||||
|
busUuid: bus.uuid,
|
||||||
|
subscriberUuid: uuid,
|
||||||
|
subscription: await bus.subscribe(eventKey, (event: T) => {
|
||||||
|
if ( event.originBusUuid !== this.uuid ) {
|
||||||
|
return handler(event)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: async () => {
|
||||||
|
this.subscribers = this.subscribers.where('uuid', '!=', uuid)
|
||||||
|
|
||||||
|
await this.subscriptions
|
||||||
|
.where('subscriberUuid', '=', uuid)
|
||||||
|
.tap(trashed => this.subscriptions.diffInPlace(trashed))
|
||||||
|
.pluck('subscription')
|
||||||
|
.awaitMapCall('unsubscribe')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect an external event bus to this bus. */
|
||||||
|
async connect(bus: EventBus): Promise<void> {
|
||||||
|
if ( this.isUp ) {
|
||||||
|
await bus.up()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectors.push(bus)
|
||||||
|
|
||||||
|
await this.subscribers
|
||||||
|
.promiseMap<BusInternalSubscription>(async subscriber => {
|
||||||
|
return {
|
||||||
|
busUuid: bus.uuid,
|
||||||
|
subscriberUuid: subscriber.uuid,
|
||||||
|
subscription: await bus.subscribe(subscriber.eventKey, event => {
|
||||||
|
if ( event.originBusUuid !== this.uuid ) {
|
||||||
|
return subscriber.handler(event)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(bus: EventBus): Promise<void> {
|
||||||
|
await this.subscriptions
|
||||||
|
.where('busUuid', '=', bus.uuid)
|
||||||
|
.tap(trashed => this.subscriptions.diffInPlace(trashed))
|
||||||
|
.pluck('subscription')
|
||||||
|
.awaitMapCall('unsubscribe')
|
||||||
|
|
||||||
|
if ( this.isUp ) {
|
||||||
|
await bus.down()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectors.diffInPlace([bus])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialize this event bus. */
|
||||||
|
async up(): Promise<void> {
|
||||||
|
if ( this.isUp ) {
|
||||||
|
this.logging.warn('Attempted to boot more than once. Skipping.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.connectors.awaitMapCall('up')
|
||||||
|
|
||||||
|
this.isUp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean up this event bus. */
|
||||||
|
async down(): Promise<void> {
|
||||||
|
if ( !this.isUp ) {
|
||||||
|
this.logging.warn('Attempted to shut down but was never properly booted. Skipping.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptions
|
||||||
|
.pluck('subscription')
|
||||||
|
.awaitMapCall('unsubscribe')
|
||||||
|
|
||||||
|
await this.connectors.awaitMapCall('down')
|
||||||
|
|
||||||
|
this.isUp = false
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,130 @@
|
|||||||
|
import {Inject, Injectable, StaticInstantiable} from '../../di'
|
||||||
|
import {BusSubscriber, Event, EventBus, EventHandler, EventHandlerReturn, EventHandlerSubscription} from './types'
|
||||||
|
import {Awaitable, Collection, Pipeline, uuid4} from '../../util'
|
||||||
|
import {Logging} from '../../service/Logging'
|
||||||
|
import {Bus, BusInternalSubscription} from './Bus'
|
||||||
|
import {AppClass} from '../../lifecycle/AppClass'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-connectable event bus implementation. Can forward events to the main Bus instance.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LocalBus<TEvent extends Event = Event> extends AppClass implements EventBus<TEvent> {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: Bus
|
||||||
|
|
||||||
|
public readonly uuid = uuid4()
|
||||||
|
|
||||||
|
/** Local listeners subscribed to events on this bus. */
|
||||||
|
protected subscribers: Collection<BusSubscriber<TEvent>> = new Collection()
|
||||||
|
|
||||||
|
protected subscriptions: Collection<BusInternalSubscription> = new Collection()
|
||||||
|
|
||||||
|
/** True if the bus has been initialized. */
|
||||||
|
private isUp = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push an event onto the bus.
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
async push(event: TEvent): Promise<void> {
|
||||||
|
if ( event.originBusUuid === this.uuid ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !event.originBusUuid ) {
|
||||||
|
event.originBusUuid = this.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( await this.callSubscribers(event) ) {
|
||||||
|
// One of the subscribers halted propagation of the event
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( await this.shouldBroadcast(event) ) {
|
||||||
|
await this.bus.push(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the given event should be pushed to connected event busses. */
|
||||||
|
protected async shouldBroadcast(event: TEvent): Promise<boolean> {
|
||||||
|
if ( typeof event.shouldBroadcast === 'function' ) {
|
||||||
|
return event.shouldBroadcast()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(event.shouldBroadcast)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call all local listeners for the given event. Returns true if the propagation
|
||||||
|
* of the event should be halted.
|
||||||
|
* @param event
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected async callSubscribers(event: TEvent): Promise<boolean> {
|
||||||
|
return this.subscribers
|
||||||
|
.filter(sub => event instanceof sub.eventKey)
|
||||||
|
.pluck('handler')
|
||||||
|
.toAsync()
|
||||||
|
.some(handler => handler(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a pipeline as an event handler. */
|
||||||
|
pipe<T extends TEvent>(eventKey: StaticInstantiable<T>, line: Pipeline<T, EventHandlerReturn>): Awaitable<EventHandlerSubscription> {
|
||||||
|
return this.subscribe(eventKey, event => line.apply(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to an event on the bus.
|
||||||
|
* @param eventKey
|
||||||
|
* @param handler
|
||||||
|
*/
|
||||||
|
async subscribe<T extends TEvent>(eventKey: StaticInstantiable<T>, handler: EventHandler<T>): Promise<EventHandlerSubscription> {
|
||||||
|
const uuid = uuid4()
|
||||||
|
|
||||||
|
this.subscribers.push({
|
||||||
|
eventName: eventKey.prototype.eventName,
|
||||||
|
handler,
|
||||||
|
eventKey,
|
||||||
|
uuid,
|
||||||
|
} as unknown as BusSubscriber<TEvent>)
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: async () => {
|
||||||
|
this.subscribers = this.subscribers.where('uuid', '!=', uuid)
|
||||||
|
|
||||||
|
await this.subscriptions
|
||||||
|
.where('subscriberUuid', '=', uuid)
|
||||||
|
.tap(trashed => this.subscriptions.diffInPlace(trashed))
|
||||||
|
.pluck('subscription')
|
||||||
|
.awaitMapCall('unsubscribe')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
if ( this.isUp ) {
|
||||||
|
this.logging.warn('Attempted to boot more than once. Skipping.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isUp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean up this event bus. */
|
||||||
|
async down(): Promise<void> {
|
||||||
|
if ( !this.isUp ) {
|
||||||
|
this.logging.warn('Attempted to shut down but was never properly booted. Skipping.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptions
|
||||||
|
.pluck('subscription')
|
||||||
|
.awaitMapCall('unsubscribe')
|
||||||
|
|
||||||
|
this.isUp = false
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,110 @@
|
|||||||
|
import {BusSubscriber, Event, EventBus, EventHandler, EventHandlerReturn, EventHandlerSubscription} from './types'
|
||||||
|
import {Inject, Injectable, StaticInstantiable} from '../../di'
|
||||||
|
import {Awaitable, Collection, Pipeline, uuid4} from '../../util'
|
||||||
|
import {Redis} from '../redis/Redis'
|
||||||
|
import {Serialization} from './serial/Serialization'
|
||||||
|
import * as IORedis from 'ioredis'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event bus implementation that does pub/sub over a Redis connection.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RedisBus implements EventBus {
|
||||||
|
@Inject()
|
||||||
|
protected readonly redis!: Redis
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly serial!: Serialization
|
||||||
|
|
||||||
|
public readonly uuid = uuid4()
|
||||||
|
|
||||||
|
/** List of events for which we have created Redis channel subscriptions. */
|
||||||
|
protected internalSubscriptions: string[] = []
|
||||||
|
|
||||||
|
/** List of local subscriptions on this bus. */
|
||||||
|
protected subscriptions: Collection<BusSubscriber<Event>> = new Collection()
|
||||||
|
|
||||||
|
protected subscriberConnection?: IORedis.Redis
|
||||||
|
|
||||||
|
protected publisherConnection?: IORedis.Redis
|
||||||
|
|
||||||
|
pipe<T extends Event>(eventKey: StaticInstantiable<T>, line: Pipeline<T, EventHandlerReturn>): Awaitable<EventHandlerSubscription> {
|
||||||
|
return this.subscribe(eventKey, event => line.apply(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
async push(event: Event): Promise<void> {
|
||||||
|
if ( !this.publisherConnection ) {
|
||||||
|
throw new Error('Cannot push to RedisQueue: publisher connection is not initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = `ex-event-${event.eventName}`
|
||||||
|
const json = await this.serial.encodeJSON(event)
|
||||||
|
|
||||||
|
await this.publisherConnection.publish(channel, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe<T extends Event>(eventKey: StaticInstantiable<T>, handler: EventHandler<T>): Promise<EventHandlerSubscription> {
|
||||||
|
const uuid = uuid4()
|
||||||
|
const subscriber: BusSubscriber<Event> = {
|
||||||
|
eventName: eventKey.prototype.eventName,
|
||||||
|
eventKey,
|
||||||
|
handler,
|
||||||
|
uuid,
|
||||||
|
} as unknown as BusSubscriber<Event>
|
||||||
|
|
||||||
|
if ( !this.internalSubscriptions.includes(subscriber.eventName) ) {
|
||||||
|
await new Promise<void>((res, rej) => {
|
||||||
|
if ( !this.subscriberConnection ) {
|
||||||
|
return rej(new Error('RedisBus not initialized on subscription.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscriberConnection.subscribe(`ex-event-${subscriber.eventName}`, err => {
|
||||||
|
if ( err ) {
|
||||||
|
return rej(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.internalSubscriptions.push(subscriber.eventName)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscriptions.push(subscriber)
|
||||||
|
|
||||||
|
return {
|
||||||
|
unsubscribe: () => {
|
||||||
|
this.subscriptions = this.subscriptions.where('uuid', '!=', uuid)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleEvent(name: string, payload: string): Promise<void> {
|
||||||
|
const event = await this.serial.decodeJSON<Event>(payload)
|
||||||
|
|
||||||
|
await this.subscriptions
|
||||||
|
.where('eventName', '=', name)
|
||||||
|
.pluck('handler')
|
||||||
|
.map(handler => handler(event))
|
||||||
|
.awaitAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.subscriberConnection = await this.redis.getNewConnection()
|
||||||
|
this.publisherConnection = await this.redis.getNewConnection()
|
||||||
|
|
||||||
|
this.subscriberConnection.on('message', (channel: string, message: string) => {
|
||||||
|
if ( !channel.startsWith('ex-event-') ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = channel.substr('ex-event-'.length)
|
||||||
|
this.handleEvent(name, message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
down(): Awaitable<void> {
|
||||||
|
this.subscriberConnection?.disconnect()
|
||||||
|
this.publisherConnection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
export * from './types'
|
||||||
|
|
||||||
|
export * from './serial/BaseSerializer'
|
||||||
|
export * from './serial/SimpleCanonicalItemSerializer'
|
||||||
|
export * from './serial/Serialization'
|
||||||
|
export * from './serial/decorators'
|
||||||
|
|
||||||
|
export * from './Bus'
|
||||||
|
export * from './LocalBus'
|
||||||
|
export * from './RedisBus'
|
||||||
|
|
||||||
|
export * from './queue/event/PushingToQueue'
|
||||||
|
export * from './queue/event/PushedToQueue'
|
||||||
|
export * from './queue/Queue'
|
||||||
|
export * from './queue/CacheQueue'
|
||||||
|
export * from './queue/SyncQueue'
|
||||||
|
export * from './queue/QueueFactory'
|
@ -0,0 +1,33 @@
|
|||||||
|
import {Queue} from './Queue'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {Cache, Maybe} from '../../../util'
|
||||||
|
import {Queueable, ShouldQueue} from '../types'
|
||||||
|
import {Serialization} from '../serial/Serialization'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue implementation that uses the configured cache driver as a queue.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CacheQueue extends Queue {
|
||||||
|
@Inject()
|
||||||
|
protected readonly cache!: Cache
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly serial!: Serialization
|
||||||
|
|
||||||
|
protected get queueIdentifier(): string {
|
||||||
|
return `extollo__queue__${this.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async push<T extends Queueable>(item: ShouldQueue<T>): Promise<void> {
|
||||||
|
const json = await this.serial.encodeJSON(item)
|
||||||
|
await this.cache.arrayPush(this.queueIdentifier, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async pop(): Promise<Maybe<ShouldQueue<Queueable>>> {
|
||||||
|
const popped = await this.cache.arrayPop(this.queueIdentifier)
|
||||||
|
if ( popped ) {
|
||||||
|
return this.serial.decodeJSON(popped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
import {BusQueue, Queueable, shouldQueue, ShouldQueue} from '../types'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {Awaitable, Maybe} from '../../../util'
|
||||||
|
import {Bus} from '../Bus'
|
||||||
|
import {PushingToQueue} from './event/PushingToQueue'
|
||||||
|
import {PushedToQueue} from './event/PushedToQueue'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Queue implements BusQueue {
|
||||||
|
@Inject()
|
||||||
|
protected readonly bus!: Bus
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async dispatch<T extends Queueable>(item: T): Promise<void> {
|
||||||
|
if ( shouldQueue(item) ) {
|
||||||
|
await this.bus.push(new PushingToQueue(item))
|
||||||
|
await this.push(item)
|
||||||
|
await this.bus.push(new PushedToQueue(item))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await item.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract push<T extends Queueable>(item: ShouldQueue<T>): Awaitable<void>
|
||||||
|
|
||||||
|
abstract pop(): Promise<Maybe<ShouldQueue<Queueable>>>
|
||||||
|
}
|
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
AbstractFactory,
|
||||||
|
Container,
|
||||||
|
DependencyRequirement,
|
||||||
|
PropertyDependency,
|
||||||
|
isInstantiable,
|
||||||
|
DEPENDENCY_KEYS_METADATA_KEY,
|
||||||
|
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
||||||
|
StaticInstantiable,
|
||||||
|
FactoryProducer,
|
||||||
|
} from '../../../di'
|
||||||
|
import {Collection, ErrorWithContext} from '../../../util'
|
||||||
|
import {Logging} from '../../../service/Logging'
|
||||||
|
import {Config} from '../../../service/Config'
|
||||||
|
import {Queue} from './Queue'
|
||||||
|
import {SyncQueue} from './SyncQueue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependency container factory that matches the abstract Queue token, but
|
||||||
|
* produces an instance of whatever Queue driver is configured in the `server.queue.driver` config.
|
||||||
|
*/
|
||||||
|
@FactoryProducer()
|
||||||
|
export class QueueFactory extends AbstractFactory<Queue> {
|
||||||
|
/** true if we have printed the synchronous queue driver warning once. */
|
||||||
|
private static loggedSyncQueueWarningOnce = false
|
||||||
|
|
||||||
|
private di(): [Logging, Config] {
|
||||||
|
return [
|
||||||
|
Container.getContainer().make(Logging),
|
||||||
|
Container.getContainer().make(Config),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
produce(): Queue {
|
||||||
|
return new (this.getQueueClass())()
|
||||||
|
}
|
||||||
|
|
||||||
|
match(something: unknown): boolean {
|
||||||
|
return something === Queue
|
||||||
|
}
|
||||||
|
|
||||||
|
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||||
|
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getQueueClass())
|
||||||
|
if ( meta ) {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return new Collection<DependencyRequirement>()
|
||||||
|
}
|
||||||
|
|
||||||
|
getInjectedProperties(): Collection<PropertyDependency> {
|
||||||
|
const meta = new Collection<PropertyDependency>()
|
||||||
|
let currentToken = this.getQueueClass()
|
||||||
|
|
||||||
|
do {
|
||||||
|
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||||
|
if ( loadedMeta ) {
|
||||||
|
meta.concat(loadedMeta)
|
||||||
|
}
|
||||||
|
currentToken = Object.getPrototypeOf(currentToken)
|
||||||
|
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the configured queue driver and return some Instantiable<Queue>.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected getQueueClass(): StaticInstantiable<Queue> {
|
||||||
|
const [logging, config] = this.di()
|
||||||
|
const QueueClass = config.get('server.queue.driver', SyncQueue)
|
||||||
|
if ( QueueClass === SyncQueue && !QueueFactory.loggedSyncQueueWarningOnce ) {
|
||||||
|
logging.warn(`You are using the default synchronous queue driver. It is recommended you configure a background queue driver instead.`)
|
||||||
|
QueueFactory.loggedSyncQueueWarningOnce = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !isInstantiable(QueueClass) || !(QueueClass.prototype instanceof Queue) ) {
|
||||||
|
const e = new ErrorWithContext('Provided queue class does not extend from @extollo/lib.Queue')
|
||||||
|
e.context = {
|
||||||
|
configKey: 'server.queue.driver',
|
||||||
|
class: QueueClass.toString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return QueueClass
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
import {Queue} from './Queue'
|
||||||
|
import {Inject, Injectable} from '../../../di'
|
||||||
|
import {Logging} from '../../../service/Logging'
|
||||||
|
import {Queueable, ShouldQueue} from '../types'
|
||||||
|
import {Maybe} from '../../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple queue implementation that executes items immediately in the current process.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SyncQueue extends Queue {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
protected async push<T extends Queueable>(item: ShouldQueue<T>): Promise<void> {
|
||||||
|
await item.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
async pop(): Promise<Maybe<ShouldQueue<Queueable>>> {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
import {Event, Queueable, ShouldQueue} from '../../types'
|
||||||
|
import {uuid4} from '../../../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired after an item is pushed to the queue.
|
||||||
|
*/
|
||||||
|
export class PushedToQueue<T extends ShouldQueue<Queueable>> implements Event {
|
||||||
|
public readonly eventName = '@extollo/lib:PushedToQueue'
|
||||||
|
|
||||||
|
public readonly eventUuid = uuid4()
|
||||||
|
|
||||||
|
public readonly shouldBroadcast = true
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly item: T,
|
||||||
|
) {}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
import {Event, Queueable, ShouldQueue} from '../../types'
|
||||||
|
import {uuid4} from '../../../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired before an item is pushed to the queue.
|
||||||
|
*/
|
||||||
|
export class PushingToQueue<T extends ShouldQueue<Queueable>> implements Event {
|
||||||
|
public readonly eventName = '@extollo/lib:PushingToQueue'
|
||||||
|
|
||||||
|
public readonly eventUuid = uuid4()
|
||||||
|
|
||||||
|
public readonly shouldBroadcast = true
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly item: T,
|
||||||
|
) {}
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
import {Awaitable, JSONState} from '../../../util'
|
||||||
|
import {SerialPayload} from '../types'
|
||||||
|
import {Serialization} from './Serialization'
|
||||||
|
import {Container, TypedDependencyKey} from '../../../di'
|
||||||
|
import {Request} from '../../../http/lifecycle/Request'
|
||||||
|
import {RequestLocalStorage} from '../../../http/RequestLocalStorage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A core Serializer implementation.
|
||||||
|
*/
|
||||||
|
export abstract class BaseSerializer<TActual, TSerial extends JSONState> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if the value can be encoded by this serializer.
|
||||||
|
* @param some
|
||||||
|
*/
|
||||||
|
public abstract matchActual(some: TActual): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if the serial payload can be decoded by this serializer.
|
||||||
|
* @param serial
|
||||||
|
*/
|
||||||
|
public matchSerial(serial: SerialPayload<TActual, TSerial>): boolean {
|
||||||
|
return serial.serializer === this.getName()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the payload as a JSON state.
|
||||||
|
* @param actual
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected abstract encodeActual(actual: TActual): Awaitable<TSerial>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the payload back to the original object.
|
||||||
|
* @param serial
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected abstract decodeSerial(serial: TSerial): Awaitable<TActual>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the unique name of this serializer.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected abstract getName(): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a value to a serial payload.
|
||||||
|
* @param actual
|
||||||
|
*/
|
||||||
|
public async encode(actual: TActual): Promise<SerialPayload<TActual, TSerial>> {
|
||||||
|
return {
|
||||||
|
serializer: this.getName(),
|
||||||
|
payload: await this.encodeActual(actual),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a value from a serial payload.
|
||||||
|
* @param serial
|
||||||
|
*/
|
||||||
|
public async decode(serial: SerialPayload<TActual, TSerial>): Promise<TActual> {
|
||||||
|
return this.decodeSerial(serial.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to get an instance of the Serialization service. */
|
||||||
|
protected getSerialization(): Serialization {
|
||||||
|
return Container.getContainer()
|
||||||
|
.make(Serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper to get an instance of the global Request. */
|
||||||
|
protected getRequest(): Request {
|
||||||
|
return Container.getContainer()
|
||||||
|
.make<RequestLocalStorage>(RequestLocalStorage)
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a dependency from the container. */
|
||||||
|
protected make<T>(key: TypedDependencyKey<T>): T {
|
||||||
|
return Container.getContainer()
|
||||||
|
.make(key)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,147 @@
|
|||||||
|
import {Container, Inject, Instantiable, Singleton} from '../../../di'
|
||||||
|
import {Awaitable, Collection, ErrorWithContext, JSONState} from '../../../util'
|
||||||
|
import {Serializer, SerialPayload} from '../types'
|
||||||
|
import {Validator} from '../../../validation/Validator'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when attempting to (de-)serialize an object and a serializer cannot be found.
|
||||||
|
*/
|
||||||
|
export class NoSerializerError extends ErrorWithContext {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||||
|
constructor(object: any, context?: {[key: string]: any}) {
|
||||||
|
super('The object could not be (de-)serialized, as no compatible serializer has been registered.', {
|
||||||
|
object,
|
||||||
|
...(context || {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a value to JSON using a registered serializer.
|
||||||
|
* @throws NoSerializerError
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
export function encode<T>(value: T): Promise<string> {
|
||||||
|
return Container.getContainer()
|
||||||
|
.make<Serialization>(Serialization)
|
||||||
|
.encodeJSON(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a value from JSON using a registered serializer.
|
||||||
|
* @throws NoSerializerError
|
||||||
|
* @param payload
|
||||||
|
* @param validator
|
||||||
|
*/
|
||||||
|
export function decode<T>(payload: string, validator?: Validator<T>): Awaitable<T> {
|
||||||
|
return Container.getContainer()
|
||||||
|
.make<Serialization>(Serialization)
|
||||||
|
.decodeJSON(payload, validator)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisteredSerializer<T extends Serializer<unknown, JSONState>> {
|
||||||
|
key: Instantiable<T>,
|
||||||
|
instance?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service that manages (de-)serialization of objects.
|
||||||
|
*/
|
||||||
|
@Singleton()
|
||||||
|
export class Serialization {
|
||||||
|
@Inject()
|
||||||
|
protected readonly injector!: Container
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializers registered with the service.
|
||||||
|
* We store the DI keys and realize them as needed, rather than at register time
|
||||||
|
* since most registration is done via the @ObjectSerializer decorator and the
|
||||||
|
* ContainerBlueprint. Realizing them at that time can cause loops in the DI call
|
||||||
|
* to realizeContainer since getContainer() -> realizeContainer() -> make the serializer
|
||||||
|
* -> getContainer(). This largely defers the realization until after all the DI keys
|
||||||
|
* are registered with the global Container.
|
||||||
|
*/
|
||||||
|
protected serializers: Collection<RegisteredSerializer<Serializer<unknown, JSONState>>> = new Collection()
|
||||||
|
|
||||||
|
/** Register a new serializer with the service. */
|
||||||
|
public register(serializer: Instantiable<Serializer<unknown, JSONState>>): this {
|
||||||
|
// Prepend instead of push so that later-registered serializers are prioritized when matching
|
||||||
|
this.serializers.prepend({
|
||||||
|
key: serializer,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
protected matchActual<T>(actual: T): Serializer<T, JSONState> {
|
||||||
|
for ( const serializer of this.serializers ) {
|
||||||
|
if ( !serializer.instance ) {
|
||||||
|
serializer.instance = this.injector.make(serializer.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( serializer.instance?.matchActual(actual) ) {
|
||||||
|
return serializer.instance as Serializer<T, JSONState>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NoSerializerError(actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected matchSerial<TSerial extends JSONState>(serial: SerialPayload<unknown, TSerial>): Serializer<unknown, TSerial> {
|
||||||
|
for ( const serializer of this.serializers ) {
|
||||||
|
if ( !serializer.instance ) {
|
||||||
|
serializer.instance = this.injector.make(serializer.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( serializer.instance?.matchSerial(serial) ) {
|
||||||
|
return serializer.instance as Serializer<unknown, TSerial>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NoSerializerError(serial)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a value to its serial payload using a registered serializer, if one exists.
|
||||||
|
* @throws NoSerializerError
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
public encode<T>(value: T): Awaitable<SerialPayload<T, JSONState>> {
|
||||||
|
return this.matchActual(value).encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a value to JSON using a registered serializer, if one exists.
|
||||||
|
* @throws NoSerializerError
|
||||||
|
* @param value
|
||||||
|
*/
|
||||||
|
public async encodeJSON<T>(value: T): Promise<string> {
|
||||||
|
return JSON.stringify(await this.encode(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a serial payload to the original object using a registered serializer, if one exists.
|
||||||
|
* @throws NoSerializerError
|
||||||
|
* @param payload
|
||||||
|
* @param validator
|
||||||
|
*/
|
||||||
|
public decode<T>(payload: SerialPayload<unknown, JSONState>, validator?: Validator<T>): Awaitable<T> {
|
||||||
|
const matched = this.matchSerial(payload)
|
||||||
|
const decoded = matched.decode(payload) as Awaitable<T>
|
||||||
|
if ( validator ) {
|
||||||
|
return validator.parse(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a value from JSON using a registered serializer, if one exists.
|
||||||
|
* @throws NoSerializerError
|
||||||
|
* @param payload
|
||||||
|
* @param validator
|
||||||
|
*/
|
||||||
|
public async decodeJSON<T>(payload: string, validator?: Validator<T>): Promise<T> {
|
||||||
|
return this.decode(JSON.parse(payload), validator)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
import {CanonicalItemClass} from '../../CanonicalReceiver'
|
||||||
|
import {BaseSerializer} from './BaseSerializer'
|
||||||
|
import {Awaitable, ErrorWithContext, JSONState, Rehydratable} from '../../../util'
|
||||||
|
import {Container, Inject, Injectable} from '../../../di'
|
||||||
|
import {Canon} from '../../../service/Canon'
|
||||||
|
|
||||||
|
/** State encoded by this class. */
|
||||||
|
export interface SimpleCanonicalItemSerialState extends JSONState {
|
||||||
|
rehydrate?: JSONState
|
||||||
|
canonicalIdentifier: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A serializer implementation that serializes class instances derived from the Canon loading system.
|
||||||
|
* These instances must be CanonicalItemClass instances and take no constructor parameters.
|
||||||
|
* If the instance is Rehydratable, then the state will be (re-)stored.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SimpleCanonicalItemSerializer<TActual extends CanonicalItemClass> extends BaseSerializer<TActual, SimpleCanonicalItemSerialState> {
|
||||||
|
@Inject()
|
||||||
|
protected readonly canon!: Canon
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly container!: Container
|
||||||
|
|
||||||
|
protected decodeSerial(serial: SimpleCanonicalItemSerialState): Awaitable<TActual> {
|
||||||
|
const canon = this.canon.getFromFullyQualified(serial.canonicalIdentifier)
|
||||||
|
if ( !canon ) {
|
||||||
|
throw new ErrorWithContext('Unable to decode serialized payload: the canonical identifier was not found', {
|
||||||
|
serial,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( canon instanceof CanonicalItemClass ) {
|
||||||
|
if ( serial.rehydrate && typeof (canon as any).rehydrate === 'function' ) {
|
||||||
|
(canon as unknown as Rehydratable).rehydrate(serial.rehydrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return canon as TActual
|
||||||
|
} else if ( canon?.prototype instanceof CanonicalItemClass ) {
|
||||||
|
const inst = this.container.make(canon)
|
||||||
|
if ( serial.rehydrate && typeof (inst as any).rehydrate === 'function' ) {
|
||||||
|
(inst as unknown as Rehydratable).rehydrate(serial.rehydrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return inst as TActual
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ErrorWithContext('Attempted to instantiate serialized item into non-Canonical class', {
|
||||||
|
canon,
|
||||||
|
serial,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async encodeActual(actual: TActual): Promise<SimpleCanonicalItemSerialState> {
|
||||||
|
const ctor = actual.constructor as typeof CanonicalItemClass
|
||||||
|
const canonicalIdentifier = ctor.getFullyQualifiedCanonicalResolver()
|
||||||
|
if ( !canonicalIdentifier ) {
|
||||||
|
throw new ErrorWithContext('Unable to determine Canonical resolver for serialization.', [
|
||||||
|
actual,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: SimpleCanonicalItemSerialState = {
|
||||||
|
canonicalIdentifier,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( typeof (actual as any).dehydrate === 'function' ) {
|
||||||
|
state.rehydrate = await (actual as unknown as Rehydratable).dehydrate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getName(): string {
|
||||||
|
return '@extollo/lib:SimpleCanonicalItemSerializer'
|
||||||
|
}
|
||||||
|
|
||||||
|
matchActual(some: TActual): boolean {
|
||||||
|
return (
|
||||||
|
some instanceof CanonicalItemClass
|
||||||
|
&& some.constructor.length === 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../../../di'
|
||||||
|
import {JSONState, logIfDebugging} from '../../../util'
|
||||||
|
import {BaseSerializer} from './BaseSerializer'
|
||||||
|
import {Serialization} from './Serialization'
|
||||||
|
import {Serializer} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a class as an object serializer with the Serialization service.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const ObjectSerializer = (): <TFunction extends Instantiable<Serializer<unknown, JSONState>>>(target: TFunction) => TFunction | void => {
|
||||||
|
return (target: Instantiable<Serializer<unknown, JSONState>>) => {
|
||||||
|
if ( isInstantiableOf(target, BaseSerializer) ) {
|
||||||
|
logIfDebugging('extollo.bus.serial.decorators', 'Registering ObjectSerializer blueprint:', target)
|
||||||
|
ContainerBlueprint.getContainerBlueprint()
|
||||||
|
.onResolve<Serialization>(Serialization, serial => serial.register(target))
|
||||||
|
} else {
|
||||||
|
logIfDebugging('extollo.bus.serial.decorators', 'Skipping ObjectSerializer blueprint:', target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,94 @@
|
|||||||
|
import {Awaitable, JSONState, Maybe, Pipeline, TypeTag, uuid4} from '../../util'
|
||||||
|
import {StaticInstantiable} from '../../di'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export interface SerialPayload<TActual, TSerial extends JSONState> extends JSONState {
|
||||||
|
serializer: string
|
||||||
|
payload: TSerial
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Serializer<TActual, TSerial extends JSONState> {
|
||||||
|
matchActual(some: TActual): boolean
|
||||||
|
|
||||||
|
matchSerial(serial: SerialPayload<TActual, TSerial>): boolean
|
||||||
|
|
||||||
|
encode(actual: TActual): Awaitable<SerialPayload<TActual, TSerial>>
|
||||||
|
|
||||||
|
decode(serial: SerialPayload<TActual, TSerial>): Awaitable<TActual>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
eventUuid: string
|
||||||
|
eventName: string
|
||||||
|
originBusUuid?: string
|
||||||
|
shouldBroadcast?: boolean | (() => Awaitable<boolean>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseEvent implements Event {
|
||||||
|
public readonly eventUuid = uuid4()
|
||||||
|
|
||||||
|
public abstract eventName: string
|
||||||
|
|
||||||
|
public shouldBroadcast(): Awaitable<boolean> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventHandlerReturn = Awaitable<boolean | void | undefined>
|
||||||
|
|
||||||
|
export type EventHandler<T extends Event> = (event: T) => EventHandlerReturn
|
||||||
|
|
||||||
|
export interface EventHandlerSubscription {
|
||||||
|
unsubscribe(): Awaitable<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Queueable {
|
||||||
|
execute(): Awaitable<void>
|
||||||
|
|
||||||
|
shouldQueue?: boolean | (() => boolean)
|
||||||
|
|
||||||
|
defaultQueue?: string | (() => string)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ShouldQueue<T extends Queueable> = T & TypeTag<'@extollo/lib:ShouldQueue'>
|
||||||
|
|
||||||
|
export function shouldQueue<T extends Queueable>(something: T): something is ShouldQueue<T> {
|
||||||
|
if ( typeof something.shouldQueue === 'function' ) {
|
||||||
|
return something.shouldQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
typeof something.shouldQueue === 'undefined'
|
||||||
|
|| something.shouldQueue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventBus<TEvent extends Event = Event> {
|
||||||
|
readonly uuid: string
|
||||||
|
|
||||||
|
subscribe<T extends TEvent>(eventKey: StaticInstantiable<T>, handler: EventHandler<T>): Awaitable<EventHandlerSubscription>
|
||||||
|
|
||||||
|
pipe<T extends TEvent>(eventKey: StaticInstantiable<T>, line: Pipeline<T, EventHandlerReturn>): Awaitable<EventHandlerSubscription>
|
||||||
|
|
||||||
|
push(event: TEvent): Awaitable<void>
|
||||||
|
|
||||||
|
up(): Awaitable<void>
|
||||||
|
|
||||||
|
down(): Awaitable<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal storage format for local event bus subscribers. */
|
||||||
|
export interface BusSubscriber<T extends Event> {
|
||||||
|
uuid: string
|
||||||
|
eventName: string
|
||||||
|
eventKey: StaticInstantiable<T>
|
||||||
|
handler: EventHandler<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusQueue {
|
||||||
|
readonly name: string
|
||||||
|
|
||||||
|
dispatch<T extends Queueable>(item: T): Promise<void>
|
||||||
|
|
||||||
|
pop(): Promise<Maybe<Queueable>>
|
||||||
|
}
|
@ -1,190 +0,0 @@
|
|||||||
import {Awaitable, ErrorWithContext, JSONState, Maybe, Rehydratable, Cache} from '../../util'
|
|
||||||
import {CanonicalItemClass} from '../CanonicalReceiver'
|
|
||||||
import {Container, Inject, Injectable, isInstantiable} from '../../di'
|
|
||||||
import {Canon} from '../../service/Canon'
|
|
||||||
|
|
||||||
/** Type annotation for a Queueable that should be pushed onto a queue. */
|
|
||||||
export type ShouldQueue<T> = T & Queueable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for an object that can be pushed to/popped from a queue.
|
|
||||||
*/
|
|
||||||
export abstract class Queueable extends CanonicalItemClass implements Rehydratable {
|
|
||||||
abstract dehydrate(): Awaitable<JSONState>
|
|
||||||
|
|
||||||
abstract rehydrate(state: JSONState): Awaitable<void>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the item is popped from the queue, this method is called.
|
|
||||||
*/
|
|
||||||
public abstract execute(): Awaitable<void>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether the object should be pushed to the queue or not.
|
|
||||||
*/
|
|
||||||
public shouldQueue(): boolean {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the queue where this object should be pushed by default.
|
|
||||||
*/
|
|
||||||
public defaultQueue(): string {
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the canonical resolver so we can re-instantiate this class from the queue.
|
|
||||||
* Throw an error if it could not be determined.
|
|
||||||
*/
|
|
||||||
public getFullyQualifiedCanonicalResolver(): string {
|
|
||||||
const resolver = (this.constructor as typeof Queueable).getFullyQualifiedCanonicalResolver()
|
|
||||||
if ( !resolver ) {
|
|
||||||
throw new ErrorWithContext('Cannot push Queueable onto queue: missing canonical resolver.')
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolver
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truth function that returns true if an object implements the same interface as Queueable.
|
|
||||||
* This is done in case some external library needs to be incorporated as the base class for
|
|
||||||
* a Queueable, and cannot be made to extend Queueable.
|
|
||||||
* @param something
|
|
||||||
*/
|
|
||||||
export function isQueueable(something: unknown): something is Queueable {
|
|
||||||
if ( something instanceof Queueable ) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
typeof something === 'function'
|
|
||||||
&& typeof (something as any).dehydrate === 'function'
|
|
||||||
&& typeof (something as any).rehydrate === 'function'
|
|
||||||
&& typeof (something as any).shouldQueue === 'function'
|
|
||||||
&& typeof (something as any).defaultQueue === 'function'
|
|
||||||
&& typeof (something as any).getFullyQualifiedCanonicalResolver === 'function'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truth function that returns true if the given object is Queueable and wants to be
|
|
||||||
* pushed onto the queue.
|
|
||||||
* @param something
|
|
||||||
*/
|
|
||||||
export function shouldQueue<T>(something: T): something is ShouldQueue<T> {
|
|
||||||
return isQueueable(something) && something.shouldQueue()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A multi-node queue that accepts & reinstantiates Queueables.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* There are several queue backends your application may use. These are
|
|
||||||
* configured via the `queue` config. To get the default queue, however,
|
|
||||||
* use this class as a DI token:
|
|
||||||
* ```ts
|
|
||||||
* this.container().make<Queue>(Queue)
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This will resolve the concrete implementation configured by your app.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class Queue {
|
|
||||||
@Inject()
|
|
||||||
protected readonly cache!: Cache
|
|
||||||
|
|
||||||
@Inject()
|
|
||||||
protected readonly canon!: Canon
|
|
||||||
|
|
||||||
@Inject('injector')
|
|
||||||
protected readonly injector!: Container
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public readonly name: string,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
public get queueIdentifier(): string {
|
|
||||||
return `extollo__queue__${this.name}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the number of items waiting in the queue. */
|
|
||||||
// public abstract length(): Awaitable<number>
|
|
||||||
|
|
||||||
/** Push a new queueable onto the queue. */
|
|
||||||
public async push(item: ShouldQueue<Queueable>): Promise<void> {
|
|
||||||
const data = {
|
|
||||||
q: true,
|
|
||||||
r: item.getFullyQualifiedCanonicalResolver(),
|
|
||||||
d: await item.dehydrate(),
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove and return a queueable from the queue. */
|
|
||||||
public async pop(): Promise<Maybe<Queueable>> {
|
|
||||||
const item = await this.cache.arrayPop(this.queueIdentifier)
|
|
||||||
if ( !item ) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(item)
|
|
||||||
if ( !data.q || !data.r ) {
|
|
||||||
throw new ErrorWithContext('Cannot pop Queueable: payload is invalid.', {
|
|
||||||
data,
|
|
||||||
queueName: this.name,
|
|
||||||
queueIdentifier: this.queueIdentifier,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalItem = this.canon.getFromFullyQualified(data.r)
|
|
||||||
if ( !canonicalItem ) {
|
|
||||||
throw new ErrorWithContext('Cannot pop Queueable: canonical name is not resolvable', {
|
|
||||||
data,
|
|
||||||
queueName: this.name,
|
|
||||||
queueIdentifier: this.queueIdentifier,
|
|
||||||
canonicalName: data.r,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( !isInstantiable(canonicalItem) ) {
|
|
||||||
throw new ErrorWithContext('Cannot pop Queueable: canonical item is not instantiable', {
|
|
||||||
data,
|
|
||||||
canonicalItem,
|
|
||||||
queueName: this.name,
|
|
||||||
queueIdentifier: this.queueIdentifier,
|
|
||||||
canonicalName: data.r,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const instance = this.injector.make(canonicalItem)
|
|
||||||
if ( !isQueueable(instance) ) {
|
|
||||||
throw new ErrorWithContext('Cannot pop Queueable: canonical item instance is not Queueable', {
|
|
||||||
data,
|
|
||||||
canonicalItem,
|
|
||||||
instance,
|
|
||||||
queueName: this.name,
|
|
||||||
queueIdentifier: this.queueIdentifier,
|
|
||||||
canonicalName: data.r,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await instance.rehydrate(data.d)
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Push a raw payload onto the queue. */
|
|
||||||
public async pushRaw(item: JSONState): Promise<void> {
|
|
||||||
await this.cache.arrayPush(this.queueIdentifier, JSON.stringify(item))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Remove and return a raw payload from the queue. */
|
|
||||||
public async popRaw(): Promise<Maybe<JSONState>> {
|
|
||||||
const item = await this.cache.arrayPop(this.queueIdentifier)
|
|
||||||
if ( item ) {
|
|
||||||
return JSON.parse(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in new issue