feat/abstractiti (#340)

* progress on better error handling

* wiring onError callback through module loader and resolver

* fix error callbacks not being stored

* update onError to be record

* type alias

* wiring

* seems to work

* update error handling contract and wire more

* add command error builder

* fix merge

* progress on error handling

* naive onError handling, not tested

* progres

* proress

* progress on abstracting away iti

* seems to work

* fix tests

* better typings

* add doc

* abstracting iti

* remove onerror for this pr

* feat: better way to add dependencies

* fix tests
This commit is contained in:
Jacob Nguyen
2023-12-15 16:09:13 -06:00
committed by GitHub
parent 89f6bbb975
commit 77fb00d386
27 changed files with 413 additions and 1153 deletions

View File

@@ -1,21 +1,18 @@
import type { CommandModule,Processed, EventModule } from "../../types/core-modules";
/**
* @since 2.0.0
*/
export interface ErrorHandling {
/**
* Number of times the process should throw an error until crashing and exiting
*/
keepAlive: number;
/**
* @deprecated
* Version 4 will remove this method
*/
crash(err: Error): never;
/**
* A function that is called on every crash. Updates keepAlive.
* If keepAlive is 0, the process crashes.
* A function that is called on every throw.
* @param error
*/
updateAlive(error: Error): void;
}

View File

@@ -6,13 +6,18 @@ import type {
} from '../../types/core-modules';
import { CommandType } from '../structures';
/**
* @since 2.0.0
*/
export interface ModuleManager {
get(id: string): string | undefined;
interface MetadataAccess {
getMetadata(m: Module): CommandMeta | undefined;
setMetadata(m: Module, c: CommandMeta): void;
}
/**
* @since 2.0.0
* @internal - direct access to the module manager will be removed in version 4
*/
export interface ModuleManager extends MetadataAccess {
get(id: string): string | undefined;
set(id: string, path: string): void;
getPublishableCommands(): Promise<CommandModule[]>;
getByNameCommandType<T extends CommandType>(

View File

@@ -36,7 +36,7 @@ export function partitionPlugins(
export function treeSearch(
iAutocomplete: AutocompleteInteraction,
options: SernOptionsData[] | undefined,
): SernAutocompleteData | undefined {
): SernAutocompleteData & { parent?: string } | undefined {
if (options === undefined) return undefined;
//clone to prevent mutation of original command module
const _options = options.map(a => ({ ...a }));
@@ -68,11 +68,11 @@ export function treeSearch(
const parentAndOptionMatches =
subcommands.has(parent) && cur.name === choice.name;
if (parentAndOptionMatches) {
return cur;
return { ...cur, parent };
}
} else {
if (cur.name === choice.name) {
return cur;
return { ...cur, parent: undefined };
}
}
}

View File

@@ -2,7 +2,9 @@ import * as assert from 'assert';
import { composeRoot, useContainer } from './dependency-injection';
import type { DependencyConfiguration } from '../../types/ioc';
import { CoreContainer } from './container';
import { Result } from 'ts-results-es'
import { DefaultServices } from '../_internal';
import { AnyFunction } from '../../types/utility';
//SIDE EFFECT: GLOBAL DI
let containerSubject: CoreContainer<Partial<Dependencies>>;
@@ -20,17 +22,83 @@ export function useContainerRaw() {
return containerSubject;
}
/**
* @since 2.0.0
* @param conf a configuration for creating your project dependencies
*/
export async function makeDependencies<const T extends Dependencies>(
conf: DependencyConfiguration,
) {
const dependencyBuilder = (container: any, excluded: string[]) => {
type Insertable =
| ((container: CoreContainer<Dependencies>) => unknown )
| Record<PropertyKey, unknown>
return {
/**
* Insert a dependency into your container.
* Supply the correct key and dependency
*/
add(key: keyof Dependencies, v: Insertable) {
Result
.wrap(() => container.add({ [key]: v}))
.expect("Failed to add " + key);
},
/**
* Exclude any dependencies from being added.
* Warning: this could lead to bad errors if not used correctly
*/
exclude(...keys: (keyof Dependencies)[]) {
keys.forEach(key => excluded.push(key));
},
/**
* @param key the key of the dependency
* @param v The dependency to swap out.
* Swap out a preexisting dependency.
*/
swap(key: keyof Dependencies, v: Insertable) {
Result
.wrap(() => container.upsert({ [key]: v }))
.expect("Failed to update " + key);
},
/**
* @param key the key of the dependency
* @param cleanup Provide cleanup for the dependency at key. First parameter is the dependency itself
* @example
* ```ts
* addDisposer('dbConnection', (dbConnection) => dbConnection.end())
* ```
* Swap out a preexisting dependency.
*/
addDisposer(key: keyof Dependencies, cleanup: AnyFunction) {
Result
.wrap(() => container.addDisposer({ [key] : cleanup }))
.expect("Failed to addDisposer for" + key);
}
};
};
type CallbackBuilder = (c: ReturnType<typeof dependencyBuilder>) => any
type ValidDependencyConfig =
| CallbackBuilder
| DependencyConfiguration;
export const insertLogger = (containerSubject: CoreContainer<any>) => {
containerSubject
.upsert({'@sern/logger': () => new DefaultServices.DefaultLogging});
}
export async function makeDependencies<const T extends Dependencies>
(conf: ValidDependencyConfig) {
//Until there are more optional dependencies, just check if the logger exists
//SIDE EFFECT
containerSubject = new CoreContainer();
await composeRoot(containerSubject, conf);
if(typeof conf === 'function') {
const excluded: string[] = [];
conf(dependencyBuilder(containerSubject, excluded));
if(!excluded.includes('@sern/logger')) {
assert.ok(!containerSubject.getTokens()['@sern/logger'])
insertLogger(containerSubject);
}
containerSubject.ready();
} else {
composeRoot(containerSubject, conf);
}
return useContainer<T>();
}

View File

@@ -21,11 +21,9 @@ export class CoreContainer<T extends Partial<Dependencies>> extends Container<T,
.subscribe({ complete: unsubscribe });
(this as Container<{}, {}>)
.add({
'@sern/errors': () => new DefaultServices.DefaultErrorHandling(),
'@sern/emitter': () => new SernEmitter(),
'@sern/store': () => new ModuleStore(),
})
.add({ '@sern/errors': () => new DefaultServices.DefaultErrorHandling(),
'@sern/emitter': () => new SernEmitter(),
'@sern/store': () => new ModuleStore() })
.add(ctx => {
return {
'@sern/modules': () =>
@@ -34,9 +32,7 @@ export class CoreContainer<T extends Partial<Dependencies>> extends Container<T,
});
}
isReady() {
return this.ready$.closed;
}
override async disposeAll() {
@@ -44,9 +40,7 @@ export class CoreContainer<T extends Partial<Dependencies>> extends Container<T,
const otherDisposables = Object
.entries(this._context)
.flatMap(([key, value]) =>
'dispose' in value
? [key]
: []);
'dispose' in value ? [key] : []);
for(const key of otherDisposables) {
this.addDisposer({ [key]: (dep: Disposable) => dep.dispose() } as never);

View File

@@ -1,6 +1,5 @@
import type { CoreDependencies, DependencyConfiguration, IntoDependencies } from '../../types/ioc';
import { DefaultServices } from '../_internal';
import { useContainerRaw } from './base';
import { insertLogger, useContainerRaw } from './base';
import { CoreContainer } from './container';
/**
@@ -53,16 +52,14 @@ export function Services<const T extends (keyof Dependencies)[]>(...keys: [...T]
* Finally, update the containerSubject with the new container state
* @param conf
*/
export async function composeRoot(
export function composeRoot(
container: CoreContainer<Partial<Dependencies>>,
conf: DependencyConfiguration,
) {
//container should have no client or logger yet.
const hasLogger = conf.exclude?.has('@sern/logger');
if (!hasLogger) {
container.upsert({
'@sern/logger': () => new DefaultServices.DefaultLogging(),
});
insertLogger(container);
}
//Build the container based on the callback provided by the user
conf.build(container as CoreContainer<Omit<CoreDependencies, '@sern/client'>>);

View File

@@ -23,19 +23,21 @@ export type ModuleResult<T> = Promise<ImportPayload<T>>;
* export default commandModule({})
*/
export async function importModule<T>(absPath: string) {
let module = await import(absPath).then(esm => esm.default);
let fileModule = await import(absPath);
assert(module, `Found no export for module at ${absPath}. Forgot to ignore with "!"? (!${basename(absPath)})?`);
if ('default' in module) {
module = module.default;
let commandModule = fileModule.default;
assert(commandModule , `Found no export @ ${absPath}. Forgot to ignore with "!"? (!${basename(absPath)})?`);
if ('default' in commandModule ) {
commandModule = commandModule.default;
}
return Result
.wrap(() => module.getInstance())
.unwrapOr(module) as T;
.wrap(() => ({ module: commandModule.getInstance() }))
.unwrapOr({ module: commandModule }) as T;
}
export async function defaultModuleLoader<T extends Module>(absPath: string): ModuleResult<T> {
let module = await importModule<T>(absPath);
let { module } = await importModule<{ module: T }>(absPath);
assert(module, `Found an undefined module: ${absPath}`);
return { module, absPath };
}
@@ -51,7 +53,8 @@ export const fmtFileName = (fileName: string) => parse(fileName).name;
export function buildModuleStream<T extends Module>(
input: ObservableInput<string>,
): Observable<ImportPayload<T>> {
return from(input).pipe(mergeMap(defaultModuleLoader<T>));
return from(input)
.pipe(mergeMap(defaultModuleLoader<T>));
}
export const getFullPathTree = (dir: string) => readPaths(resolve(dir));

View File

@@ -0,0 +1,41 @@
import type { ReplyOptions } from "../../types/utility";
import type { Logging } from "../contracts";
export interface Response {
type: 'fail' | 'continue';
body?: ReplyOptions;
log?: { type: keyof Logging; message: unknown }
}
export const of = () => {
const payload = {
type: 'fail',
body: undefined,
log : undefined
} as Record<PropertyKey, unknown>
return {
/**
* @param {'fail' | 'continue'} p a status to determine if the error will
* terminate your application or continue. Warning and
*/
status: (p: 'fail' | 'continue') => {
payload.type = p;
return payload;
},
/**
* @param {keyof Logging} type Determine to log to logger[type].
* @param {T} message the message to log
*
* Log this error with the logger.
*/
log: <T=string>(type: keyof Logging, message: T) => {
payload.log = { type, message };
return payload;
},
reply: (bodyContent: ReplyOptions) => {
payload.body = bodyContent;
return payload;
}
};
}

View File

@@ -11,8 +11,8 @@ import {
import { CoreContext } from '../structures/core-context';
import { Result, Ok, Err } from 'ts-results-es';
import * as assert from 'assert';
import { ReplyOptions } from '../../types/utility';
type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | MessageReplyOptions;
/**
* @since 1.0.0
@@ -103,9 +103,9 @@ export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
public async reply(content: ReplyOptions) {
return safeUnwrap(
this.ctx
.map(m => m.reply(content as string | MessageReplyOptions))
.map(m => m.reply(content as MessageReplyOptions))
.mapErr(i =>
i.reply(content as string | InteractionReplyOptions).then(() => i.fetchReply()),
i.reply(content as InteractionReplyOptions).then(() => i.fetchReply()),
),
);
}

View File

@@ -3,3 +3,4 @@ export * from './context';
export * from './sern-emitter';
export * from './services';
export * from './module-store';
export * as CommandError from './command-error';

View File

@@ -3,18 +3,18 @@ import { ErrorHandling } from '../../contracts';
/**
* @internal
* @since 2.0.0
* Version 4.0.0 will internalize this api. Please refrain from using ModuleStore!
* Version 4.0.0 will internalize this api. Please refrain from using the defaults!
*/
export class DefaultErrorHandling implements ErrorHandling {
crash(err: Error): never {
throw err;
}
keepAlive = 5;
#keepAlive = 5;
updateAlive(err: Error) {
this.keepAlive--;
if (this.keepAlive === 0) {
this.#keepAlive--;
if (this.#keepAlive === 0) {
throw err;
}
}

View File

@@ -11,6 +11,7 @@ import { CommandType } from '../enums';
export class DefaultModuleManager implements ModuleManager {
constructor(private moduleStore: CoreModuleStore) {}
getByNameCommandType<T extends CommandType>(name: string, commandType: T) {
const id = this.get(Id.create(name, commandType));
if (!id) {

View File

@@ -9,23 +9,16 @@ import {
SernError,
} from '../core/_internal';
import { createResultResolver } from './event-utils';
import { AutocompleteInteraction, BaseInteraction, Message } from 'discord.js';
import { BaseInteraction, Message } from 'discord.js';
import { CommandType, Context } from '../core';
import type { Args } from '../types/utility';
import type { BothCommand, CommandModule, Module, Processed } from '../types/core-modules';
import type { AnyFunction, Args } from '../types/utility';
import type { CommandModule, Module, Processed } from '../types/core-modules';
function dispatchAutocomplete(payload: {
module: Processed<BothCommand>;
event: AutocompleteInteraction;
}) {
const option = treeSearch(payload.event, payload.module.options);
assert.ok(
option,
Error(SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`),
);
//TODO: refactor dispatchers so that it implements a strategy for each different type of payload?
export function dispatchMessage(module: Processed<CommandModule>, args: [Context, Args]) {
return {
module: option.command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [payload.event],
module,
args,
};
}
@@ -36,16 +29,16 @@ export function contextArgs(wrappable: Message | BaseInteraction, messageArgs?:
}
function intoPayload(module: Processed<Module>) {
function intoPayload(module: Processed<Module>, ) {
return pipe(
arrayifySource,
map(args => ({ module, args })),
map(args => ({ module, args, })),
);
}
const createResult = createResultResolver<
Processed<Module>,
{ module: Processed<Module>; args: unknown[] },
{ module: Processed<Module>; args: unknown[] },
unknown[]
>({
createStream: ({ module, args }) => from(module.onEvent).pipe(callPlugin(args)),
@@ -56,7 +49,7 @@ const createResult = createResultResolver<
* @param module
* @param source
*/
export function eventDispatcher(module: Processed<Module>, source: unknown) {
export function eventDispatcher(module: Processed<Module>, source: unknown) {
assert.ok(source instanceof EventEmitter, `${source} is not an EventEmitter`);
const execute: OperatorFunction<unknown[], unknown> = concatMap(async args =>
@@ -81,13 +74,18 @@ export function createDispatcher(payload: {
case CommandType.Slash:
case CommandType.Both: {
if (isAutocomplete(payload.event)) {
/**
* Autocomplete is a special case that
* must be handled separately, since it's
* too different from regular command modules
* CAST SAFETY: payload is already guaranteed to be a slash command or both command
*/
return dispatchAutocomplete(payload as never);
const option = treeSearch(payload.event, payload.module.options);
assert.ok(
option,
Error(SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`),
);
const { command, name, parent } = option;
return {
...payload,
module: command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [payload.event],
};
}
return {
module: payload.module,

View File

@@ -28,7 +28,7 @@ import { contextArgs, createDispatcher } from './dispatchers';
import { ObservableInput, pipe } from 'rxjs';
import { SernEmitter } from '../core';
import { Err, Ok, Result } from 'ts-results-es';
import type { Awaitable } from '../types/utility';
import type { AnyFunction, Awaitable } from '../types/utility';
import type { ControlPlugin } from '../types/core-plugin';
import type { AnyModule, CommandModule, Module, Processed } from '../types/core-modules';
import type { ImportPayload } from '../types/core';
@@ -78,7 +78,10 @@ export function createInteractionHandler<T extends Interaction>(
return Files
.defaultModuleLoader<Processed<CommandModule>>(fullPath)
.then(payload =>
Ok(createDispatcher({ module: payload.module, event })));
Ok(createDispatcher({
module: payload.module,
event,
})));
},
);
}
@@ -93,13 +96,14 @@ export function createMessageHandler(
const fullPath = mg.get(`${prefix}_A1`);
if(!fullPath) {
return Err('Possibly undefined behavior: could not find a static id to resolve ')
return Err('Possibly undefined behavior: could not find a static id to resolve')
}
return Files
.defaultModuleLoader<Processed<CommandModule>>(fullPath)
.then(({ module })=> {
.then((payload)=> {
const args = contextArgs(event, rest);
return Ok({ module, args });
return Ok({ args, ...payload });
});
});
}
@@ -130,6 +134,12 @@ export function buildModules<T extends AnyModule>(
.pipe(assignDefaults(moduleManager));
}
interface ExecutePayload {
module: Processed<Module>;
task: () => Awaitable<unknown>;
args: unknown[]
}
/**
* Wraps the task in a Result as a try / catch.
* if the task is ok, an event is emitted and the stream becomes empty
@@ -140,13 +150,13 @@ export function buildModules<T extends AnyModule>(
*/
export function executeModule(
emitter: Emitter,
logger: Logging|undefined,
errHandler: ErrorHandling,
{
module,
task,
}: {
module: Processed<Module>;
task: () => Awaitable<unknown>;
},
args
}: ExecutePayload,
) {
return of(module).pipe(
//converting the task into a promise so rxjs can resolve the Awaitable properly
@@ -155,9 +165,9 @@ export function executeModule(
if (result.isOk()) {
emitter.emit('module.activate', SernEmitter.success(module));
return EMPTY;
} else {
return throwError(() => SernEmitter.failure(module, result.error));
}
}
return throwError(() => SernEmitter.failure(module, result.error));
}),
);
}
@@ -208,7 +218,7 @@ export function callInitPlugins<T extends Processed<AnyModule>>(sernEmitter: Emi
},
onNext: ({ module }) => {
sernEmitter.emit('module.register', SernEmitter.success(module));
return module;
return { module };
},
}),
);
@@ -220,16 +230,22 @@ export function callInitPlugins<T extends Processed<AnyModule>>(sernEmitter: Emi
*/
export function makeModuleExecutor<
M extends Processed<Module>,
Args extends { module: M; args: unknown[] },
Args extends {
module: M;
args: unknown[];
},
>(onStop: (m: M) => unknown) {
const onNext = ({ args, module }: Args) => ({
task: () => module.execute(...args),
module,
args
});
return concatMap(
createResultResolver({
onStop,
createStream: ({ args, module }) => from(module.onEvent).pipe(callPlugin(args)),
createStream: ({ args, module }) =>
from(module.onEvent)
.pipe(callPlugin(args)),
onNext,
}),
);

View File

@@ -13,7 +13,7 @@ import {
import { createInteractionHandler, executeModule, makeModuleExecutor } from './_internal';
import type { DependencyList } from '../types/ioc';
export function interactionHandler([emitter, , , modules, client]: DependencyList) {
export function interactionHandler([emitter, err, log, modules, client]: DependencyList) {
const interactionStream$ = sharedEventStream<Interaction>(client, 'interactionCreate');
const handle = createInteractionHandler(interactionStream$, modules);
@@ -28,6 +28,6 @@ export function interactionHandler([emitter, , , modules, client]: DependencyLis
filterTap(e => emitter.emit('warning', SernEmitter.warning(e))),
makeModuleExecutor(module =>
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure))),
mergeMap(payload => executeModule(emitter, payload)),
mergeMap(payload => executeModule(emitter, log, err, payload)),
);
}

View File

@@ -23,7 +23,7 @@ function hasPrefix(prefix: string, content: string) {
}
export function messageHandler(
[emitter, , log, modules, client]: DependencyList,
[emitter, err, log, modules, client]: DependencyList,
defaultPrefix: string | undefined,
) {
if (!defaultPrefix) {
@@ -42,6 +42,6 @@ export function messageHandler(
makeModuleExecutor(module => {
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure));
}),
mergeMap(payload => executeModule(emitter, payload)),
mergeMap(payload => executeModule(emitter, log, err, payload)),
);
}

View File

@@ -17,10 +17,9 @@ export function startReadyEvent(
return concat(ready$, buildModules<AnyModule>(allPaths, moduleManager))
.pipe(callInitPlugins(sEmitter))
.subscribe(module => {
register(moduleManager, module).expect(
SernError.InvalidModuleType + ' ' + util.inspect(module),
);
.subscribe(({ module }) => {
register(moduleManager, module)
.expect(SernError.InvalidModuleType + ' ' + util.inspect(module));
});
}

View File

@@ -4,32 +4,33 @@ import { SernError } from '../core/_internal';
import { buildModules, callInitPlugins, handleCrash, eventDispatcher } from './_internal';
import { Service } from '../core/ioc';
import type { DependencyList } from '../types/ioc';
import type { CommandModule, EventModule, Processed } from '../types/core-modules';
import type { EventModule, Processed } from '../types/core-modules';
export function eventsHandler(
[emitter, err, log, moduleManager, client]: DependencyList,
allPaths: ObservableInput<string>,
) {
//code smell
const intoDispatcher = (e: Processed<EventModule | CommandModule>) => {
switch (e.type) {
const intoDispatcher = (e: { module: Processed<EventModule> }) => {
switch (e.module.type) {
case EventType.Sern:
return eventDispatcher(e, emitter);
return eventDispatcher(e.module, emitter);
case EventType.Discord:
return eventDispatcher(e, client);
return eventDispatcher(e.module, client);
case EventType.External:
return eventDispatcher(e, Service(e.emitter));
return eventDispatcher(e.module, Service(e.module.emitter));
default:
throw Error(SernError.InvalidModuleType + ' while creating event handler');
}
};
buildModules<EventModule>(allPaths, moduleManager)
.pipe(callInitPlugins(emitter),
map(intoDispatcher),
/**
* Where all events are turned on
*/
mergeAll(),
handleCrash(err, log))
.pipe(
callInitPlugins(emitter),
map(intoDispatcher),
/**
* Where all events are turned on
*/
mergeAll(),
handleCrash(err, log))
.subscribe();
}

View File

@@ -19,6 +19,8 @@ import { CommandType, Context, EventType } from '../../src/core';
import { AnyCommandPlugin, AnyEventPlugin, ControlPlugin, InitPlugin } from './core-plugin';
import { Awaitable, Args, SlashOptions, SernEventsMapping } from './utility';
export interface CommandMeta {
fullPath: string;
id: string;

View File

@@ -1,3 +1,4 @@
export interface ImportPayload<T> {
module: T;
absPath: string;

View File

@@ -1,6 +1,6 @@
import { CommandInteractionOptionResolver } from 'discord.js';
import { PayloadType } from '../core';
import { AnyModule } from './core-modules';
import type { CommandInteractionOptionResolver, InteractionReplyOptions, MessageReplyOptions } from 'discord.js';
import type { PayloadType } from '../core';
import type { AnyModule } from './core-modules';
export type Awaitable<T> = PromiseLike<T> | T;
@@ -27,3 +27,7 @@ export type Payload =
| { type: PayloadType.Success; module: AnyModule }
| { type: PayloadType.Failure; module?: AnyModule; reason: string | Error }
| { type: PayloadType.Warning; reason: string };
export type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | MessageReplyOptions;