From d8683a89a173725327c511de35169e73b4d71443 Mon Sep 17 00:00:00 2001 From: Theodor Diaconu Date: Mon, 7 Oct 2024 01:08:09 +0300 Subject: [PATCH] feat: various features and stability fixes, added easy way to display logs and hook the logger in, added order for events, circular dependency check for middlewares, etc.. --- README.md | 205 ++++++++++++------ src/__tests__/index.ts | 1 + src/__tests__/models/Logger.test.ts | 45 +++- .../models/ResourceInitializer.test.ts | 9 +- src/__tests__/models/Store.test.ts | 6 +- src/__tests__/models/TaskRunner.test.ts | 7 +- src/__tests__/run.hooks.test.ts | 31 --- src/__tests__/run.middleware.test.ts | 26 +++ src/__tests__/typesafety.test.ts | 127 +++++++++++ src/defs.ts | 60 +++-- src/examples/express-mongo/index.ts | 1 + src/globalEvents.ts | 1 + src/models/DependencyProcessor.ts | 26 ++- src/models/EventManager.ts | 1 + src/models/Logger.ts | 52 +++-- src/models/ResourceInitializer.ts | 9 +- src/models/Store.ts | 23 +- src/models/TaskRunner.ts | 9 +- src/run.ts | 29 +-- 19 files changed, 496 insertions(+), 172 deletions(-) create mode 100644 src/__tests__/typesafety.test.ts create mode 100644 src/examples/express-mongo/index.ts diff --git a/README.md b/README.md index d07baa3..1fa60ab 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,7 @@ Docs

-BlueLibs Runner is a framework that provides a functional approach to building applications, whether small or large-scale. Its core concepts include Tasks, Resources, Events, and Middleware. Tasks represent the units of logic, while resources are singletons that provide shared services across the application. Events facilitate communication between different parts of the system, and middleware allows interception and modification of task execution. The framework emphasizes an async-first philosophy, ensuring that all operations are executed asynchronously for smoother application flow. - -## Pros - -- Simplifies dependency injection -- Encourages functional programming -- Provides a clear structure for building applications -- Ensures type safety with TypeScript -- Facilitates communication between different parts of the system -- Allows interception and modification of task execution -- Ensures all operations are executed asynchronously -- Provides a clean and organized way to build applications -- Encourages a modular and reusable codebase -- Facilitates testing and debugging -- Offers a flexible and extensible architecture +BlueLibs Runner is a framework that provides a functional approach to building applications, whether small or large-scale. Its core concepts include Tasks, Resources, Events, and Middleware. Tasks represent the units of logic, while resources are singletons that provide shared services across the application. Events facilitate communication between different parts of the system, and Middleware allows interception and modification of task execution. The framework emphasizes an async-first philosophy, ensuring that all operations are executed asynchronously for smoother application flow. ## Building Blocks @@ -37,11 +23,11 @@ These are the concepts and philosophy: - **Explicit Registration**: All tasks, resources, events, and middleware have to be explicitly registered to be used. - **Dependencies**: Tasks, resources, and middleware can have access to each other by depending on one another and event emitters. This is a powerful way to explicitly declare the dependencies. -Resources return through `async init()` their value to the container which can be used throughout the application. +Resources return their value to the container using the async `init()` function, making them available throughout the application. -Tasks return through `async run()` function and the value from run, can be used throughout the application. +Tasks provide their output through the async `run()` function, allowing the results to be used across the application. -All tasks, resources, events, and middleware have to be explicitly registered to be used. Registration can only be done in resources. +All tasks, resources, events, and middleware must be explicitly registered to be used. Registration can only be done within resources. ## Installation @@ -52,7 +38,7 @@ npm install @bluelibs/runner ## Basic Usage ```typescript -import { task, run, resource } from "@bluelibs/runner"; +import { run, resource } from "@bluelibs/runner"; const minimal = resource({ async init() { @@ -67,9 +53,9 @@ run(minimal).then((result) => { ## Resources and Tasks -Resources are singletons. They can be constants, services, functions, etc. They can depend on other resources, tasks, and event emitters. +Resources are singletons and can include constants, services, functions, and more. They can depend on other resources, tasks, and event emitters. -On the other hand, tasks are designed to be trackable units of logic. Like things that handle a specific route on your HTTP server, or any kind of action that is needed from various places. This will allow you to easily track what is happening in your application. +Tasks are designed to be trackable units of logic, such as handling specific routes on your HTTP server or performing actions needed by different parts of the application. This makes it easy to monitor what’s happening in your application. ```ts import { task, run, resource } from "@bluelibs/runner"; @@ -95,17 +81,17 @@ const result = await run(app); // "Hello World!" ### When to use each? -It is unrealistic to create a task for everything you're doing in your system, not only it will be tedious for the developer, but it will affect performance unnecessarily. The idea is to think of a task of something that you want trackable as an action, for example: +It is unrealistic to create a task for everything you're doing in your system, not only it will be tedious for the developer, but it will affect performance unnecessarily. The idea is to think of a task of something that you want trackable as a higher-level action, for example: - "app.user.register" - this is a task, registers the user, returns a token -- "app.user.createComment" - this is a task, creates a comment, returns the comment +- "app.user.createComment" - this is a task, creates a comment, returns the comment maybe - "app.user.updateFriendList" - this task can be re-used from many other tasks or resources as necessary Resources are more like services, they are singletons, they are meant to be used as a shared functionality across your application. They can be constants, services, functions, etc. ### Resource dispose() -Resources can have a `dispose()` method that can be used for cleanups. This is useful for cleaning up resources like closing database connections, etc. You typically want to use this when you have opened pending connections or you need to do some cleanup or a graceful shutdown. +Resources can include a `dispose()` method for cleanup tasks. This is useful for actions like closing database connections. You should use `dispose()` when you have open connections or need to perform cleanup during a graceful shutdown. ```ts import { task, run, resource } from "@bluelibs/runner"; @@ -122,7 +108,7 @@ const dbResource = resource({ }); ``` -If you want to call dispose, you have to do it through the global resource called `store`, as everything is encapsulated. So you have to reach the "insides" of the system to begin disposal. +To call dispose(), you need to use the global resource called store, since everything is encapsulated. This allows you to access the internal parts of the system to start the disposal process. ```ts import { task, run, resource, globals } from "@bluelibs/runner"; @@ -146,18 +132,19 @@ const value = await run(app); await value.dispose(); ``` -### Resource with() +### Resource configuration -Resources can be configured with a configuration object. This is useful when you want to pass in configuration to them. For example, you're building a library and you're initialising a mailer service, you can pass in the SMTP credentials as a configuration. +Resources can be set up with a configuration object, which is helpful for passing in specific settings. For example, if you’re building a library and initializing a mailer service, you can provide the SMTP credentials through this configuration. ```ts import { task, run, resource } from "@bluelibs/runner"; type Config = { smtpUrl: string; defaultFrom: string }; + const emailerResource = resource({ // automatic type inference. async init(config: Config) { - // run config checks + // todo: perform config checks with a library like zod return { sendEmail: async (to: string, subject: string, body: string) => { // send *email* @@ -214,9 +201,9 @@ const app = resource({ run(app); ``` -We have a circular dependency checker which ensures consistency. If a circular dependency is detected, an error will be thrown showing you the exact pathways. +We have a circular dependency checker to ensure consistency. If a circular dependency is found, an error will be thrown, showing the exact paths involved. -Tasks are not limited to this constraint, actions can use depend on each other freely. +Tasks, however, are not bound by this restriction; they can freely depend on each other as needed. The dependencies get injected as follows: @@ -245,7 +232,6 @@ const root = resource({ dependencies: { afterRegisterEvent, }, - async init(_, deps) { // the event becomes a function that you run with the propper payload await deps.afterRegisterEvent({ userId: string }); @@ -267,7 +253,7 @@ const afterRegisterEvent = event<{ userId: string }>({ const helloTask = task({ id: "app.hello", on: afterRegisterEvent, - priority: 0, // this is the order in which the task will be executed when `on` is present + listenerPriority: 0, // this is the order in which the task will be executed when `on` is present run(event) { console.log("User has been registered!"); }, @@ -303,7 +289,7 @@ const root = resource({ hooks: [ { event: global.events.afterInit, - priority: 1000, // event priority, the higher the number, the sooner it will run + order: -1000, // event priority, the lower the number, the sooner it will run. async run(event, deps) { // both dependencies and event are properly infered through typescript console.log("User has been registered!"); @@ -311,7 +297,7 @@ const root = resource({ }, ], async init(_, deps) { - deps.afterRegisterEvent({ userId: "XXX" }); + await deps.afterRegisterEvent({ userId: "XXX" }); }, }); ``` @@ -361,7 +347,7 @@ The hooks from a `resource` are mostly used for configuration, and blending in t ## Middleware -Middleware is a way to intercept the execution of tasks or initialization of resources. It's a powerful way to add additional functionality. First middleware that gets registered is the first that runs, giving it a form of priority, the last middleware that runs is 'closest' to the task, most likely the last element inside `middleware` array at task level. +Middleware intercepts the execution of tasks or the initialization of resources, providing a powerful means to enhance functionality. The order in which middleware is registered dictates its execution priority: the first middleware registered is the first to run, while the last middleware in the middleware array at the task level is the closest to the task itself, executing just before the task completes. (Imagine an onion if you will, with the task at the core.) ```ts import { task, resource, run, event } from "@bluelibs/runner"; @@ -476,7 +462,7 @@ const helloWorld = resource({ }); ``` -## Metadata +## Meta You can attach metadata to tasks, resources, events, and middleware. @@ -496,7 +482,7 @@ const helloWorld = task({ }); ``` -This is particularly helpful to use in conjunction with global middlewares, or global events, they can read some meta tag definition and act accordingly. +This is particularly helpful to use in conjunction with global middlewares, or global events, they can read some meta tag definition and act accordingly, decorate them or log them. The interfaces look like this: @@ -523,7 +509,7 @@ We expose direct access to the following internal services: - TaskRunner (can run tasks definitions directly and within D.I. context) - EventManager (can emit and listen to events) -Attention, it is not recommended to use these services directly, but they are exposed for advanced use-cases, for when you do not have any other way. +Attention, we do not encourage you to use these services directly, unless you really have to, they are exposed for advanced use-case scenarios. ```ts import { task, run, event, globals } from "@bluelibs/runner"; @@ -598,9 +584,11 @@ Now you can freely use any of the tasks, resources, events, and middlewares from This approach is very powerful when you have multiple packages and you want to compose them together. -## Real world examples +## Real life + +Or is it just fantasy? -Typically you have an express server (to handle HTTP requests), a database, and a bunch of services. You can define all of these in a single file and run them. +Typically, an application consists of an Express server (to handle HTTP requests), a database, and various services. You can conveniently define all of these components within a single file and execute them together. ```ts import { task, resource, run, event } from "@bluelibs/runner"; @@ -640,11 +628,11 @@ const app = resource({ run(); ``` -The system is smart enough to know which `init()` to call first. Typically all dependencies are initialised first. If there are circular dependencies, an error will be thrown with the exact paths. +The system intelligently determines the order in which init() functions should be called, ensuring that all dependencies are initialized first. In the case of circular dependencies, it will throw an error, providing the exact paths to help identify the issue. ### Business config -There's a resource for that! You can define a resource that holds your business configuration. +Or just simple config, you can do it for your business logic, environment variables, etc. ```ts import { resource, run } from "@bluelibs/runner"; @@ -768,15 +756,19 @@ You can add many services or external things into the runner ecosystem with thin ```ts import { task, run, event } from "@bluelibs/runner"; -const expressResource = resource({ +// proxy declaration pattern +const expressResource = resource({ id: "app.helloWorld", - run: async (config) => config, + run: async (app: express.Application) => app, }); const app = resource({ id: "app", register: [expressResource.with(express())], - init: async (express) => { + dependencies: { + express: expressResource, + }, + init: async (_, { express }) => { express.get("/", (req, res) => { res.send("Hello World!"); }); @@ -786,12 +778,16 @@ const app = resource({ run(app); ``` -This shows how easy you encapsulate an external service into the runner ecosystem. This 'pattern' of storing objects like this is not that common because usually they require a configuration with propper options and stuff, not an express instance(), like this: +This demonstrates how effortlessly an external service can be encapsulated within the runner ecosystem. This ‘pattern’ of storing objects in this manner is quite unique, as it typically involves configurations with various options, rather than directly using an Express instance like this: ```ts +type Config = { + port: number; +}; + const expressResource = resource({ id: "app.helloWorld", - run: async (config) => { + init: async (config: Config) => { const app = express(); app.listen(config.port); return app; @@ -801,7 +797,10 @@ const expressResource = resource({ const app = resource({ id: "app", register: [expressResource.with({ port: 3000 })], - init: async (express) => { + dependencies: { + express: expressResource, + }, + init: async (_, { express }) => { // type is automagically infered. express.get("/", (req, res) => { res.send("Hello World!"); @@ -845,6 +844,7 @@ const app = resource({ register: [securityResource], hooks: [ { + // careful when you listen on such events and need dependencies, you might not have them computed yet due to how early these events happen in the system. event: securityResource.events.afterInit, async run(event, deps) { const { config, value } = event.data; @@ -859,7 +859,7 @@ const app = resource({ }); ``` -Another approach is to create a new event that holds the config and it allows it to be updated. +Another approach is to create a new event that contains the configuration, providing the flexibility to update it as needed. ```ts import { resource, run, event } from "@bluelibs/runner"; @@ -876,6 +876,7 @@ const securityResource = resource({ async init(config: SecurityOptions) { // Give the ability to other listeners to modify the configuration securityConfigurationPhaseEvent(config); + Objecte.freeze(config); return { // ... based on config @@ -900,7 +901,7 @@ const app = resource({ ### Overrides -Previously, we explored how we can extend functionality through events. However, sometimes you want to override a resource with a new one or simply swap out a task or a middleware that you import from another package and they don't offer the ability. +Previously, we discussed how to extend functionality using events. However, there are times when you need to replace an existing resource with a new one or swap out a task or middleware imported from another package that doesn’t support such changes. ```ts import { resource, run, event } from "@bluelibs/runner"; @@ -927,15 +928,13 @@ const app = resource({ }); ``` -Now the `securityResource` will be overriden by the new one and whenever it's used it will use the new one. +The new securityResource will replace the existing one, ensuring all future references point to the updated version. -Overrides can only happen once and only if the overriden resource is registered. If two resources try to override the same resource, an error will be thrown. +Overrides work if the resource being overridden is already registered. If multiple resources attempt to override the same one, no error will be thrown. This is a common scenario, where the root resource typically contains the most authoritative overrides. But it's also to be mindful about. ## Logging -We expose through globals a logger that you can use to log things. - -By default logs are not printed unless a resource listens to the log event. This is by design, when something is logged an event is emitted. You can listen to this event and print the logs. +We expose through globals a `logger` that you can use to log things. Essentially what this service does it emits a `global.events.log` event with an `ILog` object. ```ts import { task, run, event, globals } from "@bluelibs/runner"; @@ -965,19 +964,23 @@ const helloWorld = task({ ### Print logs -Logs don't get printed by default in this system. +Logs don't get printed by default. You have to set the print threshold to a certain level. This is useful when you want to print only errors and critical logs in production, but you want to print all logs in development. Your codebase, your rules. + +To showcase the versatility of the system, here are some ways you could do it: ```ts import { task, run, event, globals, resource } from "@bluelibs/runner"; +const { logger } = globals.resources; + const printLog = task({ - id: "app.task.printLog", - on: globals.events.log, - dependencies: { - logger: globals.resources.logger, - }, - run: async (event, { logger }) => { - logger.print(event); + id: "app.task.updatePrintThreshold", + on: logger.events.afterInit, + // Note: logger is + run: async (event, deps) => { + const logger = event.data.value; + logger.setPrintThreshold("trace"); // will print all logs + logger.setPrintThreshold("error"); // will print only "error" and "critical" logs }, }); @@ -989,17 +992,79 @@ const app = resource({ // Now your app will print all logs ``` -You can in theory do it in `hooks` as well, but as specified `hooks` are mostly used for configuration and blending in the system. +You can also achieve this using hooks: -The logger's `log()` function is async as it works with events. If you don't want your system hanging on logs, simply omit the `await` +```ts +resource({ + id: "root", + hooks: [ + { + // after logger gets initialised as a resource, I'm going to set the print threshold + event: logger.events.afterInit, + async run(event) { + const logger = event.data; // do not depend on the logger + logger.setPrintThreshold("trace"); + }, + }, + ], +}); +``` + +The logger’s log() function is asynchronous because it handles events. If you want to prevent your system from waiting for log operations to complete, simply omit the await when calling log(). This is useful if you have listeners that send logs to external log storage systems. + +Additionally, there is a `global.events.log` event available. You can use this event both to emit log messages and to listen for all log activities. + +```ts +import { task, run, event, globals } from "@bluelibs/runner"; + +const { logger } = globals.resources; + +const shipLogsToWarehouse = task({ + id: "app.task.shipLogsToWarehouse", + on: logger.events.log, + dependencies: { + warehouseService: warehouseServiceResource, + }, + run: async (event, deps) => { + const log = event.data; // ILog + if (log.level === "error" || log.level === "critical") { + // Ensure no extra log() calls are made here to prevent infinite loops + await deps.warehouseService.push(log); + } + }, +}); +``` + +And yes, this would also work: + +```ts +const task = task({ + id: "app.task.logSomething", + dependencies: { + log: globals.events.log, + }, + run: async (_, { log }) => { + await log({ + level: "info", + data: { anything: "you want" }; + timestamp: new Date(); + context: "app.task.logSomething"; // optional + }) + }, +}); +``` + +Fair Warning: If you plan to use the global.events.log event, ensure you avoid creating a circular dependency. This event is emitted by the logger itself. Additionally, some logs are sent before all resources are fully initialized. Therefore, it’s important to carefully review and verify your dependencies to prevent potential issues. ## Testing +Oh yes, testing is a breeze with this system. You can easily test your tasks, resources, and middleware by running them in a test environment. It's designed to be tested. + ### Unit Testing -You can easily test your resources and tasks by running them in a test environment. +You can easily test your middleware, resources and tasks by running them in a test environment. -The only bits that you need to test are the `run` function and the `init` functions with the propper dependencies. +The only components you need to test are the run function and the init functions, along with their proper dependencies. ```ts import { task, resource } from "@bluelibs/runner"; @@ -1037,7 +1102,7 @@ describe("app.helloWorldResource", () => { ### Integration -Unit testing can be very simply with mocks, since all dependencies are explicit. However, if you would like to run an integration test, and have a task be tested and within the full container. +Unit testing becomes straightforward with mocks, as all dependencies are explicitly defined. However, if you wish to run an integration test, you can have a task tested within the full container environment. ```ts import { task, resource, run, global } from "@bluelibs/runner"; @@ -1055,7 +1120,7 @@ const app = resource({ }); ``` -Then your tests can now be cleaner: +Then your tests can now be cleaner, as you can use `overrides` and a wrapper resource to mock your task. ```ts describe("app", () => { @@ -1063,7 +1128,7 @@ describe("app", () => { const testApp = resource({ id: "app.test", register: [myApp], // wrap your existing app - overrides: [override], // apply the overrides + overrides: [override], // apply the overrides for "app.myTask" init: async (_, deps) => { // you can now test a task simply by depending on it, and running it, then asserting the response of run() }, diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 00e0043..27308f5 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -12,3 +12,4 @@ import "./run.overrides.test"; import "./run.middleware.test"; import "./globalEvents.test"; import "./errors.test"; +import "./typesafety.test"; diff --git a/src/__tests__/models/Logger.test.ts b/src/__tests__/models/Logger.test.ts index 32fb3db..0ae5ae0 100644 --- a/src/__tests__/models/Logger.test.ts +++ b/src/__tests__/models/Logger.test.ts @@ -133,8 +133,51 @@ describe("Logger", () => { await logger[level]("Test log message"); - expect(logSpy).toHaveBeenCalledWith(level, "Test log message"); + expect(logSpy).toHaveBeenCalledWith( + level, + "Test log message", + undefined // the context parameter + ); }); } }); + + it("should auto-print logs based on autoPrintLogsAfter option", async () => { + const autoPrintLevel: LogLevels = "warn"; + logger.setPrintThreshold(autoPrintLevel); + const consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); + + const levels: Array = [ + "trace", + "debug", + "info", + "warn", + "error", + "critical", + ]; + + for (const level of levels) { + logger.setPrintThreshold(level); + await logger.log(level, `Test ${level} message`); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(`Test ${level} message`) + ); + } + + // ensure events with a higher level thatn auto print level are printed, and lower levels are not + logger.setPrintThreshold("error"); + await logger.log("info", "xx Test info message"); + await logger.log("error", "xx Test error message"); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining("xx Test error message") + ); + + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringContaining("xx Test info message") + ); + + consoleLogSpy.mockRestore(); + }); }); diff --git a/src/__tests__/models/ResourceInitializer.test.ts b/src/__tests__/models/ResourceInitializer.test.ts index e517145..f6b2329 100644 --- a/src/__tests__/models/ResourceInitializer.test.ts +++ b/src/__tests__/models/ResourceInitializer.test.ts @@ -2,16 +2,19 @@ import { ResourceInitializer } from "../../models/ResourceInitializer"; import { Store } from "../../models/Store"; import { EventManager } from "../../models/EventManager"; import { defineResource } from "../../define"; +import { Logger } from "../../models"; describe("ResourceInitializer", () => { let store: Store; let eventManager: EventManager; + let logger: Logger; let resourceInitializer: ResourceInitializer; beforeEach(() => { eventManager = new EventManager(); - store = new Store(eventManager); - resourceInitializer = new ResourceInitializer(store, eventManager); + logger = new Logger(eventManager); + store = new Store(eventManager, logger); + resourceInitializer = new ResourceInitializer(store, eventManager, logger); }); it("should initialize a resource and emit events", async () => { @@ -71,7 +74,6 @@ describe("ResourceInitializer", () => { mockConfig, mockDependencies ); - expect(emitSpy).toHaveBeenCalledTimes(4); expect(emitSpy).toHaveBeenCalledWith(mockResource.events.beforeInit, { config: mockConfig, }); @@ -98,7 +100,6 @@ describe("ResourceInitializer", () => { ); expect(result).toBeUndefined(); - expect(emitSpy).toHaveBeenCalledTimes(4); expect(emitSpy).toHaveBeenCalledWith(mockResource.events.beforeInit, { config: mockConfig, }); diff --git a/src/__tests__/models/Store.test.ts b/src/__tests__/models/Store.test.ts index 9c844f8..c7d55f1 100644 --- a/src/__tests__/models/Store.test.ts +++ b/src/__tests__/models/Store.test.ts @@ -6,15 +6,17 @@ import { defineMiddleware, defineEvent, } from "../../define"; -import { globalResources } from "../../globalResources"; +import { Logger } from "../../models"; describe("Store", () => { let eventManager: EventManager; let store: Store; + let logger: Logger; beforeEach(() => { eventManager = new EventManager(); - store = new Store(eventManager); + logger = new Logger(eventManager); + store = new Store(eventManager, logger); }); it("should initialize the store with a root resource", () => { diff --git a/src/__tests__/models/TaskRunner.test.ts b/src/__tests__/models/TaskRunner.test.ts index 779f0fe..278d008 100644 --- a/src/__tests__/models/TaskRunner.test.ts +++ b/src/__tests__/models/TaskRunner.test.ts @@ -3,16 +3,19 @@ import { Store } from "../../models/Store"; import { EventManager } from "../../models/EventManager"; import { defineTask, defineResource, defineMiddleware } from "../../define"; import { ITask } from "../../defs"; +import { Logger } from "../../models"; describe("TaskRunner", () => { let store: Store; let eventManager: EventManager; let taskRunner: TaskRunner; + let logger: Logger; beforeEach(() => { eventManager = new EventManager(); - store = new Store(eventManager); - taskRunner = new TaskRunner(store, eventManager); + logger = new Logger(eventManager); + store = new Store(eventManager, logger); + taskRunner = new TaskRunner(store, eventManager, logger); }); it("should run an task without middleware", async () => { diff --git a/src/__tests__/run.hooks.test.ts b/src/__tests__/run.hooks.test.ts index c19fd33..47f883f 100644 --- a/src/__tests__/run.hooks.test.ts +++ b/src/__tests__/run.hooks.test.ts @@ -106,36 +106,5 @@ describe("run", () => { await run(app); }); - - it("should have propper type safety", async () => { - const hookEvent = defineEvent<{ message: string }>({ id: "hook.event" }); - - const task = defineTask({ - id: "task", - run: async () => "Task executed", - }); - - const testResource = defineResource({ - id: "test.resource", - dependencies: { task }, - init: async () => "Resource Value", - hooks: [ - { - event: hookEvent, - run: (event, deps) => { - // @ts-expect-error - event.data.x; - - event.data.message; - deps.task; - // @ts-expect-error - deps.task2; - }, - }, - ], - }); - - expect(true).toBe(true); - }); }); }); diff --git a/src/__tests__/run.middleware.test.ts b/src/__tests__/run.middleware.test.ts index 8f61152..ad55292 100644 --- a/src/__tests__/run.middleware.test.ts +++ b/src/__tests__/run.middleware.test.ts @@ -220,4 +220,30 @@ describe("Middleware", () => { expect(await run(app)).toBe("Middleware: Middleware: Sub initialized"); }); + + it("Should prevent circular dependencies when middleware depends on the same task", async () => { + const middleware = defineMiddleware({ + id: "middleware", + dependencies: () => ({ task }), + run: async (_, { task }) => { + // example + }, + }); + + const task = defineTask({ + id: "task", + middleware: [middleware], + run: async () => "Task executed", + }); + + const app = defineResource({ + id: "sub", + async init(_, {}) { + return "Sub initialized"; + }, + register: [middleware, task], + }); + + expect(run(app)).rejects.toThrowError(/Circular dependencies detected/); + }); }); diff --git a/src/__tests__/typesafety.test.ts b/src/__tests__/typesafety.test.ts new file mode 100644 index 0000000..128e372 --- /dev/null +++ b/src/__tests__/typesafety.test.ts @@ -0,0 +1,127 @@ +import { defineEvent, defineTask, defineResource } from "../define"; +import { RegisterableItems } from "../defs"; + +describe("typesafety", () => { + it("tasks, resources: should have propper type safety for dependeices", async () => { + type InputTask = { + message: string; + }; + + const event = defineEvent<{ message: string }>({ + id: "event", + }); + + const baseTask = defineTask({ + id: "task", + run: async (input: InputTask) => "Task executed", + }); + + const task = defineTask({ + id: "task", + dependencies: { baseTask, event }, + run: async (input: InputTask, deps) => { + deps.event({ message: input.message }); + // @ts-expect-error + deps.event({ messagex: input.message }); + + deps.baseTask({ + message: "Hello, World!", + }); + + deps.baseTask({ + // @ts-expect-error + messagex: 123, + }); + + // bc no arguments and its required + // @ts-expect-error + deps.baseTask(); + }, + }); + + type ResourceType = { + ok: boolean; + }; + + const dummyResource = defineResource({ + id: "dummy.resource", + init: async (config: ResourceType) => "Resource Value", + }); + const dummyResourceNoConfig = defineResource({ + id: "dummy.resource", + init: async () => "Resource Value", + }); + + const testResource = defineResource({ + id: "test.resource", + dependencies: { task, dummyResource, event }, + init: async (_, deps) => { + const result = await deps.task({ + message: "Hello, World!", + }); + + deps.event({ message: "Hello, World!" }); + // @ts-expect-error + deps.event(); + // @ts-expect-error + deps.event({ messagex: "Hello, World!" }); + + // @ts-expect-error + deps.dummyResource as number; + + deps.dummyResource as string; + + // @ts-expect-error + result === 1; + + // @ts-expect-error + deps.task2; + }, + register: () => [ + dummyResourceNoConfig, + // @ts-expect-error + dummyResourceNoConfig.with("hello"), + // @ts-expect-error + dummyResourceNoConfig.with({ anyObject: true }), + dummyResource.with({ ok: true }), + // @ts-expect-error + dummyResource.with({ ok: 123 }), + // @ts-expect-error + dummyResource.with(), + ], + }); + + expect(true).toBe(true); + }); + + it("events: should have propper type safety", async () => { + const hookEvent = defineEvent<{ message: string }>({ id: "hook.event" }); + + const task = defineTask({ + id: "task", + run: async () => "Task executed", + }); + + const testResource = defineResource({ + id: "test.resource", + dependencies: { task }, + init: async () => "Resource Value", + hooks: [ + { + event: hookEvent, + run: (event, deps) => { + // @ts-expect-error + event.data.x; + + event.data.message; + deps.task; + // @ts-expect-error + deps.task2; + }, + }, + ], + }); + + expect(true).toBe(true); + }); +}); diff --git a/src/defs.ts b/src/defs.ts index 042b573..5c5f196 100644 --- a/src/defs.ts +++ b/src/defs.ts @@ -24,17 +24,26 @@ export type DependencyMapType = Record< ITask | IResource | IEventDefinition | IResourceWithConfig >; -export type DependencyValueType = T extends ITask< - infer I, - infer O, - /** The infer D, while not used is crucial for making this work correctly, otherwise it forces input: unknown to a dependency that has a dependency. */ - infer D -> - ? (...args: I extends unknown ? [] : [I]) => O - : T extends IResource +// Helper Types for Extracting Generics +type ExtractTaskInput = T extends ITask ? I : never; +type ExtractTaskOutput = T extends ITask ? O : never; +type ExtractResourceValue = T extends IResource ? V - : T extends IEventDefinition - ? (input: P) => Promise | never + : never; +type ExtractEventParams = T extends IEventDefinition ? P : never; + +// Helper Types for Dependency Value Construction +type TaskDependency = (...args: I extends null | void ? [] : [I]) => O; +type ResourceDependency = V; +type EventDependency

= (input: P) => Promise; + +// Main DependencyValueType Definition +export type DependencyValueType = T extends ITask + ? TaskDependency, ExtractTaskOutput> + : T extends IResource + ? ResourceDependency> + : T extends IEventDefinition + ? EventDependency> : never; export type DependencyValuesType = { @@ -43,8 +52,8 @@ export type DependencyValuesType = { // RegisterableItems Type with Conditional Inclusion export type RegisterableItems = - | IResource // Always include IResource | IResourceWithConfig + | IResource | ITaskDefinition | IMiddlewareDefinition | IEventDefinition; @@ -58,12 +67,15 @@ export interface ITaskDefinition< id: string; dependencies?: TDependencies | (() => TDependencies); middleware?: IMiddlewareDefinition[]; + /** + * Listen to events in a simple way + */ on?: IEventDefinition; /** - * This represents the order of the event. It only makes sense to be used when `on` is also defined. - * You use this when you have multiple tasks listening to the same event and want to control the order. + * This makes sense only when `on` is specified to provide the order of the execution. + * The event with the lowest order will be executed first. */ - priority?: number; + listenerOrder?: number; meta?: ITaskMeta; run: ( input: TEventDefinitionInput extends null ? TInput : TEventDefinitionInput, @@ -125,10 +137,11 @@ export interface ITask< } // Resource interfaces export interface IResourceDefinintion< - TConfig = void, + TConfig = any, TValue = unknown, TDependencies extends DependencyMapType = {}, - THooks = any + THooks = any, + TRegisterableItems = any > { id: string; dependencies?: TDependencies | ((config: TConfig) => TDependencies); @@ -243,15 +256,24 @@ export interface IMiddlewareExecutionInput { next: (taskInputOrResourceConfig?: any) => Promise; } -export interface IHookDefinition { +export interface IHookDefinition< + D extends DependencyMapType = {}, + T = any, + B extends boolean = false +> { event: "*" | IEventDefinition; /** * The higher the number, the higher the priority. * We recommend using numbers between -1000 and 1000. */ - priority?: number; + order?: number; + /** + * These are hooks that run before any resource instantiation. + * @param event + */ + early?: B; run: ( event: IEvent, - dependencies: DependencyValuesType + dependencies: T extends true ? void : DependencyValuesType ) => Promise | void; } diff --git a/src/examples/express-mongo/index.ts b/src/examples/express-mongo/index.ts new file mode 100644 index 0000000..70b786d --- /dev/null +++ b/src/examples/express-mongo/index.ts @@ -0,0 +1 @@ +// TODO diff --git a/src/globalEvents.ts b/src/globalEvents.ts index 0aef0c8..4791c97 100644 --- a/src/globalEvents.ts +++ b/src/globalEvents.ts @@ -59,6 +59,7 @@ export const globalEvents = { }; export const globalEventsArray = [ + globalEvents.log, globalEvents.beforeInit, globalEvents.afterInit, globalEvents.tasks.beforeRun, diff --git a/src/models/DependencyProcessor.ts b/src/models/DependencyProcessor.ts index e4496f2..09df87d 100644 --- a/src/models/DependencyProcessor.ts +++ b/src/models/DependencyProcessor.ts @@ -12,6 +12,7 @@ import { EventManager } from "./EventManager"; import { ResourceInitializer } from "./ResourceInitializer"; import { TaskRunner } from "./TaskRunner"; import { Errors } from "../errors"; +import { Logger } from "./Logger"; /** * This class is responsible of setting up dependencies with their respective computedValues. @@ -24,9 +25,14 @@ export class DependencyProcessor { constructor( protected readonly store: Store, protected readonly eventManager: EventManager, - protected readonly taskRunner: TaskRunner + protected readonly taskRunner: TaskRunner, + protected readonly logger: Logger ) { - this.resourceInitializer = new ResourceInitializer(store, eventManager); + this.resourceInitializer = new ResourceInitializer( + store, + eventManager, + logger + ); } /** @@ -45,6 +51,10 @@ export class DependencyProcessor { for (const resource of this.store.resources.values()) { await this.processResourceDependencies(resource); } + + // leftovers that were registered but not depended upon, except root + // they should still be initialized as they might extend other + await this.initializeUninitializedResources(); } private async computeTaskDependencies( @@ -62,6 +72,11 @@ export class DependencyProcessor { this.eventManager.addListener( eventDefinition, async (receivedEvent) => { + this.logger.debug({ + message: `Task ${task.task.id} listened to event: ${eventDefinition.id}`, + event: receivedEvent, + }); + return this.taskRunner.run( task.task, receivedEvent, @@ -69,7 +84,7 @@ export class DependencyProcessor { ); }, { - order: task.task.priority || 0, + order: task.task.listenerOrder || 0, } ); } @@ -120,10 +135,8 @@ export class DependencyProcessor { /** * Processes all hooks, should run before emission of any event. - * @returns */ public attachHooks() { - // iterate through resources and send them to processHooks for (const resource of this.store.resources.values()) { if (resource.resource.hooks) { this.attachHooksToResource(resource); @@ -150,7 +163,7 @@ export class DependencyProcessor { for (const hook of hooks) { const event = hook.event; - const order = hook.priority || 0; + const order = hook.order || 0; if (event === "*") { this.eventManager.addGlobalListener( async (receivedEvent) => { @@ -214,6 +227,7 @@ export class DependencyProcessor { */ extractEventDependency(object: IEventDefinition>) { return async (input) => { + this.logger.debug(`Emitting event ${object.id}`); return this.eventManager.emit(object, input); }; } diff --git a/src/models/EventManager.ts b/src/models/EventManager.ts index 450e92b..16309df 100644 --- a/src/models/EventManager.ts +++ b/src/models/EventManager.ts @@ -1,5 +1,6 @@ import { EventHandlerType, IEvent, IEventDefinition } from "../defs"; import { Errors } from "../errors"; +import { Logger } from "./Logger"; const HandlerOptionsDefaults = { order: 0 }; diff --git a/src/models/Logger.ts b/src/models/Logger.ts index 8bc86ed..abb4ad4 100644 --- a/src/models/Logger.ts +++ b/src/models/Logger.ts @@ -17,7 +17,7 @@ export interface ILog { } export class Logger { - public static defaultContext = "app"; + printThreshold: LogLevels | null = null; public severity = { trace: 0, @@ -28,23 +28,43 @@ export class Logger { critical: 5, }; - constructor(private eventManager: EventManager) {} + constructor(protected eventManager: EventManager) {} /** * @param level * @param message */ - public async log(level: LogLevels, data: any): Promise { + public async log( + level: LogLevels, + data: any, + source?: string + ): Promise { const log: ILog = { level, data, + context: source, timestamp: new Date(), }; + if ( + this.printThreshold && + this.severity[level] >= this.severity[this.printThreshold] + ) { + this.print(log); + } + await this.eventManager.emit(globalEvents.log, log); } - public async print(log: ILog) { + /** + * Will print logs after that, use `null` to disable autoprinting. + * @param level + */ + public setPrintThreshold(level: LogLevels | null) { + this.printThreshold = level; + } + + public print(log: ILog) { // Extract the relevant information from the log const { level, context, data, timestamp } = log; @@ -74,27 +94,27 @@ export class Logger { console.log(logMessage); } - public async info(data: any) { - await this.log("info", data); + public async info(data: any, context?: string) { + await this.log("info", data, context); } - public async error(data: any) { - await this.log("error", data); + public async error(data: any, context?: string) { + await this.log("error", data, context); } - public async warn(data: any) { - await this.log("warn", data); + public async warn(data: any, context?: string) { + await this.log("warn", data, context); } - public async debug(data: any) { - await this.log("debug", data); + public async debug(data: any, context?: string) { + await this.log("debug", data, context); } - public async trace(data: any) { - await this.log("trace", data); + public async trace(data: any, context?: string) { + await this.log("trace", data, context); } - public async critical(data: any) { - await this.log("critical", data); + public async critical(data: any, context?: string) { + await this.log("critical", data, context); } } diff --git a/src/models/ResourceInitializer.ts b/src/models/ResourceInitializer.ts index 7b4771e..eaf444b 100644 --- a/src/models/ResourceInitializer.ts +++ b/src/models/ResourceInitializer.ts @@ -7,11 +7,13 @@ import { import { EventManager } from "./EventManager"; import { globalEvents } from "../globalEvents"; import { MiddlewareStoreElementType, Store } from "./Store"; +import { Logger } from "./Logger"; export class ResourceInitializer { constructor( protected readonly store: Store, - protected readonly eventManager: EventManager + protected readonly eventManager: EventManager, + protected readonly logger: Logger ) {} /** @@ -27,9 +29,6 @@ export class ResourceInitializer { config: TConfig, dependencies: DependencyValuesType ): Promise { - // begin by dispatching the event of creating it. - // then ensure the hooks are called - // then ensure the middleware are called await this.eventManager.emit(globalEvents.resources.beforeInit, { config, resource, @@ -52,6 +51,8 @@ export class ResourceInitializer { value, }); + this.logger.debug(`Resource ${resource.id} initialized`); + return value; } catch (e) { error = e; diff --git a/src/models/Store.ts b/src/models/Store.ts index b99a2eb..6c9c5b4 100644 --- a/src/models/Store.ts +++ b/src/models/Store.ts @@ -17,6 +17,7 @@ import { Errors } from "../errors"; import { globalResources } from "../globalResources"; import { EventManager } from "./EventManager"; import { TaskRunner } from "./TaskRunner"; +import { Logger } from "./Logger"; export type ResourceStoreElementType< C = any, @@ -67,7 +68,10 @@ export class Store { #isLocked = false; #isInitialized = false; - constructor(protected readonly eventManager: EventManager) {} + constructor( + protected readonly eventManager: EventManager, + protected readonly logger: Logger + ) {} get isLocked() { return this.#isLocked; @@ -378,12 +382,22 @@ export class Store { }); } + private middlewareAsMap(middleware: IMiddlewareDefinition[]) { + return middleware.reduce((acc, item) => { + acc[item.id] = item; + return acc; + }, {} as Record); + } + getDependentNodes(): IDependentNode[] { const depenedants: IDependentNode[] = []; for (const task of this.tasks.values()) { depenedants.push({ id: task.task.id, - dependencies: task.task.dependencies, + dependencies: { + ...task.task.dependencies, + ...this.middlewareAsMap(task.task.middleware), + }, }); } for (const middleware of this.middlewares.values()) { @@ -395,7 +409,10 @@ export class Store { for (const resource of this.resources.values()) { depenedants.push({ id: resource.resource.id, - dependencies: resource.resource.dependencies || {}, + dependencies: { + ...resource.resource.dependencies, + ...this.middlewareAsMap(resource.resource.middleware), + }, }); } diff --git a/src/models/TaskRunner.ts b/src/models/TaskRunner.ts index bc621eb..5276707 100644 --- a/src/models/TaskRunner.ts +++ b/src/models/TaskRunner.ts @@ -7,6 +7,7 @@ import { Store, TaskStoreElementType, } from "./Store"; +import { Logger } from "./Logger"; export class TaskRunner { protected readonly runnerStore = new Map< @@ -16,7 +17,8 @@ export class TaskRunner { constructor( protected readonly store: Store, - protected readonly eventManager: EventManager + protected readonly eventManager: EventManager, + protected readonly logger: Logger ) {} /** @@ -104,6 +106,11 @@ export class TaskRunner { ) { // this is the final next() let next = async (input) => { + this.logger.debug({ + message: `Running task ${task.id}`, + input, + }); + return task.run.call(null, input, taskDependencies as any); }; diff --git a/src/run.ts b/src/run.ts index daa831a..a705222 100644 --- a/src/run.ts +++ b/src/run.ts @@ -52,21 +52,23 @@ export type RunnerState = { middleware: Record; }; -export type RunnerType = { - store: Store; - eventManager: EventManager; - taskRunner: TaskRunner; -}; - export async function run( resource: IResource, config?: C ): Promise { const eventManager = new EventManager(); + + // ensure for logger, that it can be used only after: computeAllDependencies() has executed const logger = new Logger(eventManager); - const store = new Store(eventManager); - const taskRunner = new TaskRunner(store, eventManager); - const processor = new DependencyProcessor(store, eventManager, taskRunner); + + const store = new Store(eventManager, logger); + const taskRunner = new TaskRunner(store, eventManager, logger); + const processor = new DependencyProcessor( + store, + eventManager, + taskRunner, + logger + ); // In the registration phase we register deeply all the resources, tasks, middleware and events store.initializeStore(resource, config); @@ -80,24 +82,25 @@ export async function run( throw Errors.circularDependencies(circularDependencies.cycles); } + // the overrides that were registered now will override the other registered resources await store.processOverrides(); - // a form of hooking, we store the events for all tasks + // a form of hooking, we create the events for all tasks and store them so they can be referenced await store.storeEventsForAllTasks(); await processor.attachHooks(); await processor.computeAllDependencies(); + await logger.debug("All elements have been initalized.."); + // Now we can safely compute dependencies without being afraid of an infinite loop. // The hooking part is done here. await eventManager.emit(globalEvents.beforeInit); - // leftovers that were registered but not depended upon, except root - await processor.initializeUninitializedResources(); - // Now we can initialise the root resource await processor.initializeRoot(); await eventManager.emit(globalEvents.afterInit); + await logger.debug("System initialized and operational."); // disallow manipulation or attaching more store.lock();