Refactor event bus and queue system; detect cycles in DI realization and make
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Garrett Mills 2022-01-26 19:37:54 -06:00
parent 506fb55c74
commit 6d1cf18680
69 changed files with 1673 additions and 720 deletions

View File

@ -1,10 +1,10 @@
import {Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {Awaitable, Maybe} from '../../util'
import {Authenticatable, AuthenticatableRepository} from '../types'
import {Logging} from '../../service/Logging'
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
import {UserFlushedEvent} from '../event/UserFlushedEvent'
import {Bus} from '../../support/bus'
/**
* Base-class for a context that authenticates users and manages security.
@ -12,7 +12,7 @@ import {UserFlushedEvent} from '../event/UserFlushedEvent'
@Injectable()
export abstract class SecurityContext {
@Inject()
protected readonly bus!: EventBus
protected readonly bus!: Bus
@Inject()
protected readonly logging!: Logging
@ -40,7 +40,7 @@ export abstract class SecurityContext {
*/
async authenticateOnce(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
await this.bus.push(new UserAuthenticatedEvent(user, this))
}
/**
@ -50,7 +50,7 @@ export abstract class SecurityContext {
async authenticate(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.persist()
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
await this.bus.push(new UserAuthenticatedEvent(user, this))
}
/**
@ -60,7 +60,7 @@ export abstract class SecurityContext {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.bus.dispatch(new UserFlushedEvent(user, this))
await this.bus.push(new UserFlushedEvent(user, this))
}
}
@ -72,7 +72,7 @@ export abstract class SecurityContext {
if ( user ) {
this.authenticatedUser = undefined
await this.persist()
await this.bus.dispatch(new UserFlushedEvent(user, this))
await this.bus.push(new UserFlushedEvent(user, this))
}
}

View File

@ -32,7 +32,7 @@ export class SessionSecurityContext extends SecurityContext {
const user = await this.repository.getByIdentifier(identifier)
if ( user ) {
this.authenticatedUser = user
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
}
}
}

View File

@ -1,27 +1,12 @@
import {Event} from '../../event/Event'
import {SecurityContext} from '../context/SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
import {BaseEvent} from '../../support/bus'
/**
* Event fired when a user is authenticated.
*/
export class AuthenticationEvent extends Event {
export abstract class AuthenticationEvent extends BaseEvent {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
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
}
}

View File

@ -3,4 +3,6 @@ import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event fired when a user is authenticated.
*/
export class UserAuthenticatedEvent extends AuthenticationEvent {}
export class UserAuthenticatedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserAuthenticatedEvent'
}

View File

@ -3,4 +3,6 @@ import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event raised when a user is re-authenticated to a security context
*/
export class UserAuthenticationResumedEvent extends AuthenticationEvent {}
export class UserAuthenticationResumedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserAuthenticationResumedEvent'
}

View File

@ -3,4 +3,6 @@ import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event fired when a user is unauthenticated.
*/
export class UserFlushedEvent extends AuthenticationEvent {}
export class UserFlushedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserFlushedEvent'
}

View File

@ -11,6 +11,8 @@ export * from './event/UserAuthenticatedEvent'
export * from './event/UserAuthenticationResumedEvent'
export * from './event/UserFlushedEvent'
export * from './serial/AuthenticationEventSerializer'
export * from './repository/orm/ORMUser'
export * from './repository/orm/ORMUserRepository'

View File

@ -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,
})
}
}

View File

@ -13,7 +13,7 @@ export const CLIDirective = (): ClassDecorator => {
if ( isInstantiableOf(target, Directive) ) {
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
ContainerBlueprint.getContainerBlueprint()
.onResolve<CommandLine>(CommandLine, cli => {
.onResolve<CommandLine>(CommandLine, (cli: CommandLine) => {
cli.registerDirective(target as Instantiable<Directive>)
})
} else {

View File

@ -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
}
}
}

View File

@ -9,6 +9,7 @@ import {TemplateDirective} from '../directive/TemplateDirective'
import {RunDirective} from '../directive/RunDirective'
import {RoutesDirective} from '../directive/RoutesDirective'
import {RouteDirective} from '../directive/RouteDirective'
import {WorkDirective} from '../directive/queue/WorkDirective'
/**
* Unit that takes the place of the final unit in the application that handles
@ -46,6 +47,7 @@ export class CommandLineApplication extends Unit {
this.cli.registerDirective(RunDirective)
this.cli.registerDirective(RoutesDirective)
this.cli.registerDirective(RouteDirective)
this.cli.registerDirective(WorkDirective)
const argv = process.argv.slice(2)
const match = this.cli.getDirectives()

View File

@ -8,7 +8,7 @@ import {
TypedDependencyKey,
} from './types'
import {AbstractFactory} from './factory/AbstractFactory'
import {collect, Collection, globalRegistry, logIfDebugging} from '../util'
import {collect, Collection, ErrorWithContext, globalRegistry, logIfDebugging} from '../util'
import {Factory} from './factory/Factory'
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
import {ClosureFactory} from './factory/ClosureFactory'
@ -25,6 +25,27 @@ export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resol
* A container of resolve-able dependencies that are created via inversion-of-control.
*/
export class Container {
/**
* Set to true when we're realizing a container.
* Used to prevent infinite recursion when `getContainer()` is accidentally called
* from somewhere within the `realizeContainer()` call.
*/
protected static realizingContainer = false
/**
* List of dependency keys currently being `make`'d as a reverse stack.
* This is used to detect dependency cycles.
* @protected
*/
protected static makeStack?: Collection<DependencyKey>
/**
* The 100 most recent dependency keys that were `make`'d. Used to help with
* debugging cyclic dependency errors.
* @protected
*/
protected static makeHistory?: Collection<DependencyKey>
/**
* Given a Container instance, apply the ContainerBlueprint to it.
* @param container
@ -34,6 +55,10 @@ export class Container {
.resolve()
.map(factory => container.registerFactory(factory))
ContainerBlueprint.getContainerBlueprint()
.resolveConstructable()
.map((factory: StaticClass<AbstractFactory<any>, any>) => console.log(factory)) // eslint-disable-line no-console
ContainerBlueprint.getContainerBlueprint()
.resolveConstructable()
.map((factory: StaticClass<AbstractFactory<any>, any>) => container.registerFactory(container.make(factory)))
@ -54,8 +79,14 @@ export class Container {
public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
if ( this.realizingContainer ) {
throw new ErrorWithContext('Attempted getContainer call during container realization.')
}
this.realizingContainer = true
const container = Container.realizeContainer(new Container())
globalRegistry.setGlobal('extollo/injector', container)
this.realizingContainer = false
return container
}
@ -403,13 +434,92 @@ export class Container {
* @param {...any} parameters
*/
make<T>(target: DependencyKey, ...parameters: any[]): T {
if ( !Container.makeStack ) {
Container.makeStack = new Collection()
}
if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
Container.makeStack.push(target)
if ( Container.makeHistory.length > 100 ) {
Container.makeHistory = Container.makeHistory.slice(1, 100)
}
Container.makeHistory.push(target)
this.checkForMakeCycles()
if ( this.hasKey(target) ) {
return this.resolveAndCreate(target, ...parameters)
const realized = this.resolveAndCreate(target, ...parameters)
Container.makeStack.pop()
return realized
} else if ( typeof target !== 'string' && isInstantiable(target) ) {
return this.produceFactory(new Factory(target), parameters)
} else {
const realized = this.produceFactory(new Factory(target), parameters)
Container.makeStack.pop()
return realized
}
Container.makeStack.pop()
throw new TypeError(`Invalid or unknown make target: ${target}`)
}
/**
* Check the `makeStack` for duplicates and throw an error if a dependency cycle is
* detected. This is used to prevent infinite mutual recursion when cyclic dependencies
* occur.
* @protected
*/
protected checkForMakeCycles(): void {
if ( !Container.makeStack ) {
Container.makeStack = new Collection()
}
if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
if ( Container.makeStack.unique().length !== Container.makeStack.length ) {
const displayKey = (key: DependencyKey) => {
if ( typeof key === 'string' ) {
return 'key: `' + key + '`'
} else {
return `key: ${key.name}`
}
}
const makeStack = Container.makeStack
.reverse()
.map(displayKey)
const makeHistory = Container.makeHistory
.reverse()
.map(displayKey)
console.error('Make Stack:') // eslint-disable-line no-console
console.error(makeStack.join('\n')) // eslint-disable-line no-console
console.error('Make History:') // eslint-disable-line no-console
console.error(makeHistory.join('\n')) // eslint-disable-line no-console
throw new ErrorWithContext('Cyclic dependency chain detected', {
makeStack,
makeHistory,
})
}
}
/**
* Create a new instance of the dependency key using this container, ignoring any pre-existing instances
* in this container.
* @param key
* @param parameters
*/
makeNew<T>(key: TypedDependencyKey<T>, ...parameters: any[]): T {
if ( isInstantiable(key) ) {
return this.produceFactory(new Factory(key), parameters)
}
throw new TypeError(`Invalid or unknown make target: ${key}`)
}
/**

View File

@ -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>
}

View File

@ -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
}
},
}
}
}

View File

@ -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)
}
}
}

View File

@ -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>
}

View File

@ -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
}
}

View File

@ -2,8 +2,7 @@ import {HTTPKernelModule} from '../HTTPKernelModule'
import {Inject, Injectable} from '../../../di'
import {HTTPKernel} from '../HTTPKernel'
import {Request} from '../../lifecycle/Request'
import {EventBus} from '../../../event/EventBus'
import {PropagatingEventBus} from '../../../event/PropagatingEventBus'
import {Bus} from '../../../support/bus'
/**
* HTTP kernel module that creates a request-specific event bus
@ -12,17 +11,18 @@ import {PropagatingEventBus} from '../../../event/PropagatingEventBus'
@Injectable()
export class InjectRequestEventBusHTTPModule extends HTTPKernelModule {
@Inject()
protected bus!: EventBus
protected bus!: Bus
public static register(kernel: HTTPKernel): void {
kernel.register(this).first()
}
public async apply(request: Request): Promise<Request> {
const bus = <PropagatingEventBus> this.make(PropagatingEventBus)
bus.connect(this.bus)
const requestBus = this.container().makeNew<Bus>(Bus)
await requestBus.up()
await requestBus.connect(this.bus)
request.purge(EventBus).registerProducer(EventBus, () => bus)
request.purge(Bus).registerProducer(Bus, () => requestBus)
return request
}
}

View File

@ -2,13 +2,10 @@ export * from './util'
export * from './lib'
export * from './di'
export * from './event/types'
export * from './event/Event'
export * from './event/EventBus'
export * from './event/PropagatingEventBus'
export * from './service/Logging'
export * from './support/bus/index'
export * from './lifecycle/RunLevelErrorHandler'
export * from './lifecycle/Application'
export * from './lifecycle/AppClass'
@ -30,6 +27,7 @@ export * from './http/kernel/module/AbstractResolvedRouteHandlerHTTPModule'
export * from './http/kernel/module/ExecuteResolvedRoutePreflightHTTPModule'
export * from './http/kernel/module/ExecuteResolvedRouteHandlerHTTPModule'
export * from './http/kernel/module/ExecuteResolvedRoutePostflightHTTPModule'
export * from './http/kernel/module/ClearRequestEventBusHTTPModule'
export * from './http/kernel/HTTPKernel'
export * from './http/kernel/HTTPKernelModule'
@ -89,7 +87,6 @@ export * from './support/cache/MemoryCache'
export * from './support/cache/RedisCache'
export * from './support/cache/CacheFactory'
export * from './support/NodeModules'
export * from './support/queue/Queue'
export * from './service/Queueables'

View File

@ -3,9 +3,9 @@ import {QueryResult} from '../types'
import {SQLDialect} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {QueryExecutedEvent} from './event/QueryExecutedEvent'
import {Schema} from '../schema/Schema'
import {Bus} from '../../support/bus'
/**
* Error thrown when a connection is used before it is ready.
@ -25,7 +25,7 @@ export class ConnectionNotReadyError extends ErrorWithContext {
@Injectable()
export abstract class Connection extends AppClass {
@Inject()
protected bus!: EventBus
protected readonly bus!: Bus
constructor(
/**
@ -82,6 +82,6 @@ export abstract class Connection extends AppClass {
*/
protected async queryExecuted(query: string): Promise<void> {
const event = new QueryExecutedEvent(this.name, this, query)
await this.bus.dispatch(event)
await this.bus.push(event)
}
}

View File

@ -1,67 +1,17 @@
import {Event} from '../../../event/Event'
import {Inject, Injectable} from '../../../di'
import {InvalidJSONStateError, JSONState} from '../../../util'
import {Connection} from '../Connection'
import {DatabaseService} from '../../DatabaseService'
import {BaseEvent} from '../../../support/bus'
/**
* Event fired when a query is executed.
*/
@Injectable()
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
export class QueryExecutedEvent extends BaseEvent {
constructor(
connectionName?: string,
connection?: Connection,
query?: string,
public readonly connectionName: string,
public readonly connection: Connection,
public readonly query: string,
) {
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'
}

View File

@ -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
}
}

View File

@ -1,13 +1,12 @@
import {Directive, OptionDefinition} from '../../cli'
import {Directive, OptionDefinition, CLIDirective} from '../../cli'
import {Container, Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {Collection} from '../../util'
import {Bus, EventHandlerSubscription} from '../../support/bus'
import {Migrations} from '../services/Migrations'
import {Migrator} from '../migrations/Migrator'
import {ApplyingMigrationEvent} from '../migrations/events/ApplyingMigrationEvent'
import {AppliedMigrationEvent} from '../migrations/events/AppliedMigrationEvent'
import {EventSubscription} from '../../event/types'
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
import {CLIDirective} from '../../cli/decorators'
/**
* CLI directive that applies migrations using the default Migrator.
@ -17,13 +16,13 @@ import {CLIDirective} from '../../cli/decorators'
@CLIDirective()
export class MigrateDirective extends Directive {
@Inject()
protected readonly bus!: EventBus
protected readonly bus!: Bus
@Inject('injector')
protected readonly injector!: Container
/** Event bus subscriptions. */
protected subscriptions: EventSubscription[] = []
protected subscriptions: Collection<EventHandlerSubscription> = new Collection()
getKeywords(): string | string[] {
return ['migrate']
@ -113,7 +112,6 @@ export class MigrateDirective extends Directive {
/** Remove event bus listeners before finish. */
protected async removeListeners(): Promise<void> {
await Promise.all(this.subscriptions.map(x => x.unsubscribe()))
this.subscriptions = []
await this.subscriptions.awaitMapCall('unsubscribe')
}
}

View File

@ -1,13 +1,12 @@
import {Directive, OptionDefinition} from '../../cli'
import {Directive, OptionDefinition, CLIDirective} from '../../cli'
import {Container, Inject, Injectable} from '../../di'
import {EventBus} from '../../event/EventBus'
import {Collection} from '../../util'
import {Bus, EventHandlerSubscription} from '../../support/bus'
import {Migrator} from '../migrations/Migrator'
import {Migrations} from '../services/Migrations'
import {RollingBackMigrationEvent} from '../migrations/events/RollingBackMigrationEvent'
import {RolledBackMigrationEvent} from '../migrations/events/RolledBackMigrationEvent'
import {EventSubscription} from '../../event/types'
import {NothingToMigrateError} from '../migrations/NothingToMigrateError'
import {CLIDirective} from '../../cli/decorators'
/**
* CLI directive that undoes applied migrations using the default Migrator.
@ -17,7 +16,7 @@ import {CLIDirective} from '../../cli/decorators'
@CLIDirective()
export class RollbackDirective extends Directive {
@Inject()
protected readonly bus!: EventBus
protected readonly bus!: Bus
@Inject('injector')
protected readonly injector!: Container
@ -26,7 +25,7 @@ export class RollbackDirective extends Directive {
protected readonly migrations!: Migrations
/** Event bus subscriptions. */
protected subscriptions: EventSubscription[] = []
protected subscriptions: Collection<EventHandlerSubscription> = new Collection()
getKeywords(): string | string[] {
return ['rollback']
@ -98,7 +97,6 @@ export class RollbackDirective extends Directive {
/** Remove event bus listeners before finish. */
protected async removeListeners(): Promise<void> {
await Promise.all(this.subscriptions.map(x => x.unsubscribe()))
this.subscriptions = []
await this.subscriptions.awaitMapCall('unsubscribe')
}
}

View File

@ -2,12 +2,12 @@ import {Container, Inject, Injectable} from '../../di'
import {Awaitable, collect, ErrorWithContext} from '../../util'
import {Migration} from './Migration'
import {Migrations} from '../services/Migrations'
import {EventBus} from '../../event/EventBus'
import {ApplyingMigrationEvent} from './events/ApplyingMigrationEvent'
import {AppliedMigrationEvent} from './events/AppliedMigrationEvent'
import {RollingBackMigrationEvent} from './events/RollingBackMigrationEvent'
import {RolledBackMigrationEvent} from './events/RolledBackMigrationEvent'
import {NothingToMigrateError} from './NothingToMigrateError'
import {Bus} from '../../support/bus'
/**
* Manages single-run patches/migrations.
@ -18,7 +18,7 @@ export abstract class Migrator {
protected readonly migrations!: Migrations
@Inject()
protected readonly bus!: EventBus
protected readonly bus!: Bus
@Inject('injector')
protected readonly injector!: Container
@ -193,31 +193,11 @@ export abstract class Migrator {
* @protected
*/
protected async filterAppliedMigrations(identifiers: string[]): Promise<string[]> {
return collect(identifiers)
.partialMap(identifier => {
const migration = this.migrations.get(identifier)
if ( migration ) {
return {
identifier,
migration,
}
}
})
.asyncPipe()
.tap(coll => {
return coll.promiseMap(async group => {
return {
...group,
has: await this.has(group.migration),
}
})
})
.tap(coll => {
return coll.filter(group => !group.has)
.pluck<string>('identifier')
.all()
})
.resolve()
const ids = await collect(identifiers)
.toAsync()
.filterOut(async id => this.has(this.migrations.getOrFail(id)))
return ids.all()
}
/**
@ -226,31 +206,11 @@ export abstract class Migrator {
* @protected
*/
protected async filterPendingMigrations(identifiers: string[]): Promise<string[]> {
return collect(identifiers)
.partialMap(identifier => {
const migration = this.migrations.get(identifier)
if ( migration ) {
return {
identifier,
migration,
}
}
})
.asyncPipe()
.tap(coll => {
return coll.promiseMap(async group => {
return {
...group,
has: await this.has(group.migration),
}
})
})
.tap(coll => {
return coll.filter(group => group.has)
.pluck<string>('identifier')
.all()
})
.resolve()
const ids = await collect(identifiers)
.toAsync()
.filter(async id => this.has(this.migrations.getOrFail(id)))
return ids.all()
}
/**
@ -260,7 +220,7 @@ export abstract class Migrator {
*/
protected async applying(migration: Migration): Promise<void> {
const event = <ApplyingMigrationEvent> this.injector.make(ApplyingMigrationEvent, migration)
await this.bus.dispatch(event)
await this.bus.push(event)
}
/**
@ -270,7 +230,7 @@ export abstract class Migrator {
*/
protected async applied(migration: Migration): Promise<void> {
const event = <AppliedMigrationEvent> this.injector.make(AppliedMigrationEvent, migration)
await this.bus.dispatch(event)
await this.bus.push(event)
}
/**
@ -280,7 +240,7 @@ export abstract class Migrator {
*/
protected async rollingBack(migration: Migration): Promise<void> {
const event = <RollingBackMigrationEvent> this.injector.make(RollingBackMigrationEvent, migration)
await this.bus.dispatch(event)
await this.bus.push(event)
}
/**
@ -290,6 +250,6 @@ export abstract class Migrator {
*/
protected async rolledBack(migration: Migration): Promise<void> {
const event = <RolledBackMigrationEvent> this.injector.make(RolledBackMigrationEvent, migration)
await this.bus.dispatch(event)
await this.bus.push(event)
}
}

View File

@ -5,4 +5,6 @@ import {MigrationEvent} from './MigrationEvent'
* Event fired after a migration is applied.
*/
@Injectable()
export class AppliedMigrationEvent extends MigrationEvent {}
export class AppliedMigrationEvent extends MigrationEvent {
eventName = '@extollo/lib.AppliedMigrationEvent'
}

View File

@ -5,4 +5,6 @@ import {MigrationEvent} from './MigrationEvent'
* Event fired before a migration is applied.
*/
@Injectable()
export class ApplyingMigrationEvent extends MigrationEvent {}
export class ApplyingMigrationEvent extends MigrationEvent {
eventName = '@extollo/lib.ApplyingMigrationEvent'
}

View File

@ -1,49 +1,13 @@
import {Event} from '../../../event/Event'
import {Migration} from '../Migration'
import {Inject, Injectable} from '../../../di'
import {Migrations} from '../../services/Migrations'
import {ErrorWithContext} from '../../../util'
import {BaseEvent} from '../../../support/bus'
/**
* Generic base-class for migration-related events.
*/
@Injectable()
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
}
export abstract class MigrationEvent extends BaseEvent {
constructor(
migration: Migration,
public readonly migration: Migration,
) {
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
}
}

View File

@ -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,
})
}
}

View File

@ -5,4 +5,6 @@ import {MigrationEvent} from './MigrationEvent'
* Event fired after a migration has been rolled-back.
*/
@Injectable()
export class RolledBackMigrationEvent extends MigrationEvent {}
export class RolledBackMigrationEvent extends MigrationEvent {
eventName = '@extollo/lib.RolledBackMigrationEvent'
}

View File

@ -5,4 +5,6 @@ import {MigrationEvent} from './MigrationEvent'
* Event fired before a migration is rolled back.
*/
@Injectable()
export class RollingBackMigrationEvent extends MigrationEvent {}
export class RollingBackMigrationEvent extends MigrationEvent {
eventName = '@extollo/lib.RollingBackMigrationEvent'
}

View File

@ -1,14 +1,12 @@
import {ModelKey, QueryRow, QuerySource} from '../types'
import {Container, Inject, Instantiable, isInstantiable, StaticClass} from '../../di'
import {Container, Inject, Instantiable, isInstantiable} from '../../di'
import {DatabaseService} from '../DatabaseService'
import {ModelBuilder} from './ModelBuilder'
import {getFieldsMeta, ModelField} from './Field'
import {deepCopy, Collection, Awaitable, uuid4, isKeyof, Pipeline} from '../../util'
import {deepCopy, Collection, uuid4, isKeyof, Pipeline} from '../../util'
import {EscapeValueObject} from '../dialect/SQLDialect'
import {AppClass} from '../../lifecycle/AppClass'
import {Logging} from '../../service/Logging'
import {Connection} from '../connection/Connection'
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from '../../event/types'
import {ModelRetrievedEvent} from './events/ModelRetrievedEvent'
import {ModelSavingEvent} from './events/ModelSavingEvent'
import {ModelSavedEvent} from './events/ModelSavedEvent'
@ -16,23 +14,21 @@ import {ModelUpdatingEvent} from './events/ModelUpdatingEvent'
import {ModelUpdatedEvent} from './events/ModelUpdatedEvent'
import {ModelCreatingEvent} from './events/ModelCreatingEvent'
import {ModelCreatedEvent} from './events/ModelCreatedEvent'
import {EventBus} from '../../event/EventBus'
import {Relation, RelationValue} from './relation/Relation'
import {HasOne} from './relation/HasOne'
import {HasMany} from './relation/HasMany'
import {HasOneOrMany} from './relation/HasOneOrMany'
import {Scope, ScopeClosure} from './scope/Scope'
import {LocalBus} from '../../support/bus/LocalBus'
import {ModelEvent} from './events/ModelEvent'
/**
* Base for classes that are mapped to tables in a database.
*/
export abstract class Model<T extends Model<T>> extends AppClass implements Bus {
export abstract class Model<T extends Model<T>> extends LocalBus<ModelEvent<T>> {
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly bus!: EventBus
/**
* The name of the connection this model should run through.
* @type string
@ -100,12 +96,6 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
*/
protected originalSourceRow?: QueryRow
/**
* Collection of event subscribers, by their events.
* @protected
*/
protected modelEventBusSubscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
/**
* Cache of relation instances by property accessor.
* This is used by the `@Relation()` decorator to cache Relation instances.
@ -257,7 +247,7 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
})
await this.dispatch(new ModelRetrievedEvent<T>(this as any))
await this.push(new ModelRetrievedEvent<T>(this as any))
return this
}
@ -627,11 +617,11 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
* @param withoutTimestamps
*/
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
await this.dispatch(new ModelSavingEvent<T>(this as any))
await this.push(new ModelSavingEvent<T>(this as any))
const ctor = this.constructor as typeof Model
if ( this.exists() && this.isDirty() ) {
await this.dispatch(new ModelUpdatingEvent<T>(this as any))
await this.push(new ModelUpdatingEvent<T>(this as any))
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
(this as any)[ctor.UPDATED_AT] = new Date()
@ -652,9 +642,9 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
await this.assumeFromSource(data)
}
await this.dispatch(new ModelUpdatedEvent<T>(this as any))
await this.push(new ModelUpdatedEvent<T>(this as any))
} else if ( !this.exists() ) {
await this.dispatch(new ModelCreatingEvent<T>(this as any))
await this.push(new ModelCreatingEvent<T>(this as any))
if ( !withoutTimestamps ) {
if ( ctor.timestamps && ctor.CREATED_AT ) {
@ -685,10 +675,10 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
await this.assumeFromSource(data)
}
await this.dispatch(new ModelCreatedEvent<T>(this as any))
await this.push(new ModelCreatedEvent<T>(this as any))
}
await this.dispatch(new ModelSavedEvent<T>(this as any))
await this.push(new ModelSavedEvent<T>(this as any))
return this
}
@ -825,13 +815,6 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
return !this.is(other)
}
/**
* Creates a new Pipe instance containing this model instance.
*/
public pipe<TOut>(pipeline: Pipeline<this, TOut>): TOut {
return pipeline.apply(this)
}
/**
* Get a wrapped function that compares whether the given model field
* on the current instance differs from the originally fetched value.
@ -886,46 +869,6 @@ export abstract class Model<T extends Model<T>> extends AppClass implements Bus
(this as any)[thisFieldName] = object[objectFieldName]
}
subscribe<EventT extends Dispatchable>(event: StaticClass<EventT, Instantiable<EventT>>, subscriber: EventSubscriber<EventT>): Awaitable<EventSubscription> {
const entry: EventSubscriberEntry<EventT> = {
id: uuid4(),
event,
subscriber,
}
this.modelEventBusSubscribers.push(entry)
return this.buildSubscription(entry.id)
}
unsubscribe<EventT extends Dispatchable>(subscriber: EventSubscriber<EventT>): Awaitable<void> {
this.modelEventBusSubscribers = this.modelEventBusSubscribers.where('subscriber', '!=', subscriber)
}
async dispatch(event: Dispatchable): Promise<void> {
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
await this.modelEventBusSubscribers.where('event', '=', eventClass)
.promiseMap(entry => entry.subscriber(event))
await this.bus.dispatch(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.modelEventBusSubscribers = this.modelEventBusSubscribers.where('id', '!=', id)
subscribed = false
}
},
}
}
/**
* Create a new one-to-one relation instance. Should be called from a method on the model:
*

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right after a model is inserted.
*/
export class ModelCreatedEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelCreatedEvent'
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right before a model is inserted.
*/
export class ModelCreatingEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelCreatingEvent'
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right after a model is deleted.
*/
export class ModelDeletedEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelDeletedEvent'
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right before a model is deleted.
*/
export class ModelDeletingEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelDeletingEvent'
}

View File

@ -1,31 +1,19 @@
import {Model} from '../Model'
import {Event} from '../../../event/Event'
import {JSONState} from '../../../util'
import {BaseEvent} from '../../../support/bus'
import {Awaitable} from '../../../util'
/**
* Base class for events that concern an instance of a model.
* @fixme support serialization
*/
export abstract class ModelEvent<T extends Model<T>> extends Event {
/**
* The instance of the model.
*/
public instance!: T
export abstract class ModelEvent<T extends Model<T>> extends BaseEvent {
constructor(
instance?: T,
public readonly instance: T,
) {
super()
if ( instance ) {
this.instance = instance
}
}
// TODO implement serialization here
dehydrate(): Promise<JSONState> {
return Promise.resolve({})
}
rehydrate(/* state: JSONState */): void | Promise<void> {
return undefined
shouldBroadcast(): Awaitable<boolean> {
return false
}
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right after a model's data is loaded from the source.
*/
export class ModelRetrievedEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelRetrievedEvent'
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right after a model is persisted to the source.
*/
export class ModelSavedEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelSavedEvent'
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right before a model is persisted to the source.
*/
export class ModelSavingEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelSavingEvent'
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right after a model's data is updated.
*/
export class ModelUpdatedEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelUpdatedEvent'
}

View File

@ -5,5 +5,5 @@ import {ModelEvent} from './ModelEvent'
* Event fired right before a model's data is updated.
*/
export class ModelUpdatingEvent<T extends Model<T>> extends ModelEvent<T> {
eventName = '@extollo/lib.ModelUpdatingEvent'
}

View File

@ -94,7 +94,7 @@ export class PostgresSchema extends Schema {
.type(ConstraintType.Unique)
.tap(constraint => {
collect<{column_name: string}>(uniques[key]) // eslint-disable-line camelcase
.pluck<string>('column_name')
.pluck('column_name')
.each(column => constraint.field(column))
})
.flagAsExistingInSchema()
@ -125,7 +125,7 @@ export class PostgresSchema extends Schema {
}
})
.whereNotIn('column', nonNullable.pluck('column_name'))
.pluck<string>('column')
.pluck('column')
.each(column => {
table.column(column)
.nullable()
@ -161,7 +161,7 @@ export class PostgresSchema extends Schema {
return builder
.peek(idx => {
collect<{column_name: string}>(groupedIndexes[key]) // eslint-disable-line camelcase
.pluck<string>('column_name')
.pluck('column_name')
.each(col => idx.field(col))
})
.when(groupedIndexes[key]?.[0]?.indisprimary, idx => idx.primary())

View File

@ -155,6 +155,18 @@ export abstract class Canonical<T> extends Unit {
return `${this.canonicalItem}s`
}
/** Get a canonical item by key, throwing an error if it could not be found. */
public getOrFail(key: string): T {
const result = this.get(key)
if ( !result ) {
throw new ErrorWithContext(`Unable to resolve Canonical key: ${key}`, {
key,
})
}
return result
}
/** Get a canonical item by key. */
public get(key: string): T | undefined {
if ( key.startsWith('@') ) {
@ -221,6 +233,7 @@ export abstract class Canonical<T> extends Unit {
}
public async up(): Promise<void> {
if ( await this.path.exists() ) {
for await ( const entry of this.path.walk() ) {
if ( !entry.endsWith(this.suffix) ) {
this.logging.debug(`Skipping file with invalid suffix: ${entry}`)
@ -240,6 +253,7 @@ export abstract class Canonical<T> extends Unit {
this.loadedItems[definition.canonicalName] = resolvedItem
}
}
this.canon.registerCanonical(this)
}

View File

@ -18,8 +18,8 @@ import {ParseIncomingBodyHTTPModule} from '../http/kernel/module/ParseIncomingBo
import {Config} from './Config'
import {InjectRequestEventBusHTTPModule} from '../http/kernel/module/InjectRequestEventBusHTTPModule'
import {Routing} from './Routing'
import {EventBus} from '../event/EventBus'
import {RequestLocalStorage} from '../http/RequestLocalStorage'
import {Bus} from '../support/bus'
/**
* Application unit that starts the HTTP/S server, creates Request and Response objects
@ -40,7 +40,7 @@ export class HTTPServer extends Unit {
protected readonly routing!: Routing
@Inject()
protected readonly bus!: EventBus
protected readonly bus!: Bus
@Inject()
protected readonly requestLocalStorage!: RequestLocalStorage

View File

@ -1,25 +1,15 @@
import {CanonicalStatic} from './CanonicalStatic'
import {Singleton, Instantiable, StaticClass} from '../di'
import {CanonicalDefinition} from './Canonical'
import {Queueable} from '../support/queue/Queue'
import {Singleton, Instantiable} from '../di'
import {Queueable} from '../support/bus'
/**
* A canonical unit that resolves Queueable classes from `app/queueables`.
* A canonical unit that resolves Queueable classes from `app/jobs`.
*/
@Singleton()
export class Queueables extends CanonicalStatic<Queueable, Instantiable<Queueable>> {
export class Queueables extends CanonicalStatic<Queueables, Instantiable<Queueable>> {
protected appPath = ['jobs']
protected canonicalItem = 'job'
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
}
}

View File

@ -8,9 +8,9 @@ import {ViewEngineFactory} from '../views/ViewEngineFactory'
import {ViewEngine} from '../views/ViewEngine'
import {lib} from '../lib'
import {Config} from './Config'
import {EventBus} from '../event/EventBus'
import {PackageDiscovered} from '../support/PackageDiscovered'
import {staticServer} from '../http/servers/static'
import {Bus} from '../support/bus'
/**
* Application unit that loads the various route files from `app/http/routes` and pre-compiles the route handlers.
@ -24,7 +24,7 @@ export class Routing extends Unit {
protected readonly config!: Config
@Inject()
protected readonly bus!: EventBus
protected readonly bus!: Bus
protected compiledRoutes: Collection<Route<unknown, unknown[]>> = new Collection<Route<unknown, unknown[]>>()
@ -54,7 +54,7 @@ export class Routing extends Unit {
this.logging.verbose(`${route}`)
})
this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => {
await this.bus.subscribe(PackageDiscovered, async (event: PackageDiscovered) => {
const loadFrom = event.packageConfig?.extollo?.routes?.loadFrom
if ( Array.isArray(loadFrom) ) {
for ( const path of loadFrom ) {

View File

@ -4,8 +4,8 @@ import {Inject, Injectable, InjectParam} from '../di'
import {Application} from '../lifecycle/Application'
import {Logging} from '../service/Logging'
import {NodeModule, ExtolloAwareNodeModule} from './types'
import {EventBus} from '../event/EventBus'
import {PackageDiscovered} from './PackageDiscovered'
import {Bus} from './bus'
/**
* A helper class for discovering and interacting with
@ -17,7 +17,7 @@ export class NodeModules {
protected readonly logging!: Logging
@Inject()
protected readonly bus!: EventBus
protected readonly bus!: Bus
constructor(
@InjectParam(Application.NODE_MODULES_INJECTION)
@ -102,7 +102,7 @@ export class NodeModules {
this.logging.info(`Auto-discovering package: ${key}`)
seen.push(key)
await this.bus.dispatch(new PackageDiscovered(packageJsonData, packageJson.clone()))
await this.bus.push(new PackageDiscovered(packageJsonData, packageJson.clone()))
const packageNodeModules = packageJson.concat('..', 'node_modules')
if ( await packageNodeModules.exists() && packageJsonData?.extollo?.recursiveDependencies?.discover ) {

View File

@ -1,6 +1,6 @@
import {Event} from '../event/Event'
import {Awaitable, JSONState, UniversalPath} from '../util'
import {Awaitable, UniversalPath} from '../util'
import {ExtolloAwareNodeModule} from './types'
import {BaseEvent} from './bus'
/**
* An event indicating that an NPM package has been discovered
@ -9,7 +9,7 @@ import {ExtolloAwareNodeModule} from './types'
* Application services can listen for this event to register
* various discovery logic (e.g. automatically boot units
*/
export class PackageDiscovered extends Event {
export class PackageDiscovered extends BaseEvent {
constructor(
public packageConfig: ExtolloAwareNodeModule,
public packageJson: UniversalPath,
@ -17,17 +17,9 @@ export class PackageDiscovered extends Event {
super()
}
dehydrate(): Awaitable<JSONState> {
return {
packageConfig: this.packageConfig as JSONState,
packageJson: this.packageJson.toString(),
}
}
eventName = '@extollo/lib.PackageDiscovered'
rehydrate(state: JSONState): Awaitable<void> {
if ( typeof state === 'object' ) {
this.packageConfig = (state.packageConfig as ExtolloAwareNodeModule)
this.packageJson = new UniversalPath(String(state.packageJson))
}
shouldBroadcast(): Awaitable<boolean> {
return false
}
}

189
src/support/bus/Bus.ts Normal file
View File

@ -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
}
}

130
src/support/bus/LocalBus.ts Normal file
View File

@ -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
}
}

110
src/support/bus/RedisBus.ts Normal file
View File

@ -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()
}
}

17
src/support/bus/index.ts Normal file
View File

@ -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'

View File

@ -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)
}
}
}

View File

@ -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>>>
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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,
) {}
}

View File

@ -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,
) {}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
)
}
}

View File

@ -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)
}
}
}

94
src/support/bus/types.ts Normal file
View File

@ -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>>
}

View File

@ -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)
}
}
}

View File

@ -48,14 +48,20 @@ export class Redis extends Unit {
*/
public async getConnection(): Promise<IORedis.Redis> {
if ( !this.connection ) {
const options = this.config.get('redis.connection') as RedisOptions
this.logging.verbose(options)
this.connection = new IORedis(options)
this.connection = await this.getNewConnection()
}
return this.connection
}
/**
* Get a new IORedis connection instance.
*/
public async getNewConnection(): Promise<IORedis.Redis> {
const options = this.config.get('redis.connection') as RedisOptions
return new IORedis(options)
}
/**
* Get the IORedis connection in an AsyncPipe.
*/

View File

@ -8,6 +8,7 @@ import {
import {Iterable, StopIteration} from './Iterable'
import {applyWhere, WhereOperator} from './where'
import {AsyncPipe, Pipeline} from '../support/Pipe'
import {Awaitable} from '../support/types'
type AsyncCollectionComparable<T> = CollectionItem<T>[] | Collection<T> | AsyncCollection<T>
type AsyncKeyFunction<T, T2> = (item: CollectionItem<T>, index: number) => CollectionItem<T2> | Promise<CollectionItem<T2>>
type AsyncCollectionFunction<T, T2> = (items: AsyncCollection<T>) => T2
@ -39,14 +40,26 @@ export class AsyncCollection<T> {
private async inChunksAll<T2>(key: KeyOperator<T, T2>, callback: (items: Collection<T2>) => any): Promise<void> {
await this.storedItems.chunk(this.iteratorChunkSize, async items => {
await callback(items.pluck(key))
if ( typeof key !== 'function' ) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
key = x => x[key]
}
await callback(items.map(key))
})
await this.storedItems.reset()
}
private async inChunksAllNumbers<T2>(key: KeyOperator<T, T2>, callback: (items: number[]) => any): Promise<void> {
await this.storedItems.chunk(this.iteratorChunkSize, async items => {
await callback(items.pluck(key).map(x => Number(x))
if ( typeof key !== 'function' ) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
key = x => x[key]
}
await callback(items.map(key).map(x => Number(x))
.all())
})
await this.storedItems.reset()
@ -275,12 +288,12 @@ export class AsyncCollection<T> {
* @param {function} operator - item => boolean
* @return Promise<boolean>
*/
async some(operator: (item: T) => boolean): Promise<boolean> {
async some(operator: (item: T) => Awaitable<boolean|undefined|void>): Promise<boolean> {
let contains = false
await this.inChunks(items => {
await this.inChunks(async items => {
for ( const item of items.all() ) {
if ( operator(item) ) {
if ( await operator(item) ) {
contains = true
throw new StopIteration()
}
@ -394,6 +407,14 @@ export class AsyncCollection<T> {
return new Collection<T>(newItems)
}
/**
* Like filter, but inverted. That is, filters out items that DO match the criterion.
* @param func
*/
async filterOut<T2>(func: KeyFunction<T, T2>): Promise<Collection<T>> {
return this.filter(async (...args) => !(await func(...args)))
}
/**
* Calls the passed in function if the boolean condition is true. Allows for functional syntax.
* @param {boolean} bool
@ -677,14 +698,16 @@ export class AsyncCollection<T> {
* @param {KeyOperator} key
* @return Promise<Collection>
*/
async pluck<T2>(key: KeyOperator<T, T2>): Promise<Collection<T2>> {
let newItems: CollectionItem<T2>[] = []
async pluck<T2 extends keyof T>(key: T2): Promise<Collection<T[T2]>> {
let newItems: CollectionItem<T[T2]>[] = []
await this.inChunksAll(key, async items => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
newItems = newItems.concat(items.all())
})
return new Collection<T2>(newItems)
return new Collection<T[T2]>(newItems)
}
/**

View File

@ -14,7 +14,9 @@ type MaybeCollectionIndex = CollectionIndex | undefined
type ComparisonFunction<T> = (item: CollectionItem<T>, otherItem: CollectionItem<T>) => number
import { WhereOperator, applyWhere, whereMatch } from './where'
import {Awaitable, Either, isLeft, Maybe, MethodsOf, right, unright} from '../support/types'
import {Awaitable, Awaited, Either, isLeft, Maybe, MethodsOf, right, unright} from '../support/types'
import {AsyncCollection} from './AsyncCollection'
import {ArrayIterable} from './ArrayIterable'
const collect = <T>(items: CollectionItem<T>[]): Collection<T> => Collection.collect(items)
const toString = (item: unknown): string => String(item)
@ -265,6 +267,16 @@ class Collection<T> {
return new Collection(matches)
}
/**
* Like diff, but mutates the current collection.
* @param items
*/
diffInPlace<T2>(items: CollectionComparable<T|T2>): this {
const exclude = items instanceof Collection ? items.all() : items
this.storedItems = this.storedItems.filter(item => !exclude.includes(item))
return this
}
/**
* Return a collection of items that ARE in this collection, but NOT In the `items` collection
* using a helper function to determine whether two items are equal.
@ -355,6 +367,14 @@ class Collection<T> {
return right(new Collection<TRight>(newItems))
}
/**
* Get the collection as an AsyncCollection.
*/
toAsync(): AsyncCollection<T> {
const iter = new ArrayIterable([...this.storedItems])
return new AsyncCollection<T>(iter)
}
/**
* Map a method on the underlying type, passing it any required parameters.
* This is delightfully type-safe.
@ -365,6 +385,22 @@ class Collection<T> {
return this.map(x => x[method](...params))
}
/**
* Shortcut for .mapCall(...).awaitAll().
* @param method
* @param params
*/
async awaitMapCall<T2 extends MethodsOf<T>>(method: T2, ...params: Parameters<T[T2]>): Promise<Collection<Awaited<ReturnType<T[T2]>>>> {
return this.mapCall(method, ...params).awaitAll()
}
/**
* Await all values in the collection.
*/
async awaitAll(): Promise<Collection<Awaited<T>>> {
return this.promiseMap(async x => x as Awaited<T>)
}
/**
* Map each element in the collection to a string.
*/
@ -462,6 +498,14 @@ class Collection<T> {
return new Collection(this.storedItems.filter(func ?? Boolean))
}
/**
* Like filter, but inverted. That is, removes items that DO match the criterion.
* @param func
*/
filterOut<T2>(func?: KeyFunction<T, T2>): Collection<T> {
return this.filter((...args) => !(func ?? Boolean)(...args))
}
whereDefined(): Collection<NonNullable<T>> {
return this.filter() as unknown as Collection<NonNullable<T>>
}
@ -793,8 +837,8 @@ class Collection<T> {
*
* @param key
*/
pluck<T2>(key: KeyOperator<T, T2>): Collection<T2> {
return new Collection<T2>(this.allOperator(key))
pluck<T2 extends keyof T>(key: T2): Collection<T[T2]> {
return new Collection<T[T2]>(this.allOperator(key))
}
/**
@ -1188,7 +1232,7 @@ class Collection<T> {
*
* @param func
*/
tap<T2>(func: CollectionFunction<T, T2>): Collection<T> {
tap<T2>(func: CollectionFunction<T, T2>): this {
func(this)
return this
}

View File

@ -72,3 +72,5 @@ export type TypeArraySignature<TArr extends unknown[], TReturn> = (...params: TA
export type MethodsOf<T, TMethod = (...args: any[]) => any> = {
[K in keyof T]: T[K] extends TMethod ? K : never
}[keyof T]
export type Awaited<T> = T extends PromiseLike<infer U> ? U : T