diff --git a/apps/backend/package.json b/apps/backend/package.json index 2081d5cff..2317232e4 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -8,8 +8,8 @@ "build:docker": "bun build --compile src/index.ts --target=bun --packages=external --sourcemap --outfile undb" }, "dependencies": { - "@aws-sdk/client-s3": "^3.673.0", - "@aws-sdk/s3-request-presigner": "^3.673.0", + "@aws-sdk/client-s3": "^3.674.0", + "@aws-sdk/s3-request-presigner": "^3.674.0", "@elysiajs/cors": "1.1.0", "@elysiajs/cron": "1.1.0", "@elysiajs/html": "1.1.0", @@ -25,6 +25,7 @@ "@undb/authz": "workspace:*", "@undb/base": "workspace:*", "@undb/command-handlers": "workspace:*", + "@undb/event-handlers": "workspace:*", "@undb/context": "workspace:*", "@undb/cqrs": "workspace:*", "@undb/dashboard": "workspace:*", @@ -41,7 +42,7 @@ "@undb/trpc": "workspace:*", "@undb/webhook": "workspace:*", "arctic": "^1.9.2", - "bun": "^1.1.30", + "bun": "^1.1.31", "core-js": "^3.38.1", "elysia": "1.1.7", "got": "^14.4.3", diff --git a/apps/backend/src/registry/index.ts b/apps/backend/src/registry/index.ts index 3da334fcd..45b06a497 100644 --- a/apps/backend/src/registry/index.ts +++ b/apps/backend/src/registry/index.ts @@ -1,6 +1,7 @@ import { registerCommands } from "@undb/command-handlers" import { registerQueries } from "@undb/query-handlers" +import { registerEvents } from "@undb/event-handlers" import { registerWebhook } from "../modules" import { registerStorage } from "../modules/file/storage" import { registerMail } from "../modules/mail/mail.register" @@ -13,6 +14,7 @@ export const register = () => { registerContext() registerDb() registerCommands() + registerEvents() registerQueries() registerWebhook() } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 854ec4a19..c560e59e0 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -27,8 +27,8 @@ "@types/lodash.unzip": "^3.4.9", "@types/papaparse": "^5.3.14", "@types/sortablejs": "latest", - "@typescript-eslint/eslint-plugin": "^8.9.0", - "@typescript-eslint/parser": "^8.9.0", + "@typescript-eslint/eslint-plugin": "^8.10.0", + "@typescript-eslint/parser": "^8.10.0", "@undb/commands": "workspace:*", "@undb/domain": "workspace:*", "@undb/i18n": "workspace:*", diff --git a/bun.lockb b/bun.lockb index b2f4e6e80..e0c26bf31 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 000000000..0041c35c5 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +[install] +# set default registry as a string +registry = "https://registry.npmmirror.com" \ No newline at end of file diff --git a/package.json b/package.json index 41a24da11..7053279a2 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "drizzle-kit": "^0.26.2", "husky": "^9.1.6", "lint-staged": "^15.2.10", - "npm-run-all2": "^6.2.3", + "npm-run-all2": "^6.2.4", "prettier": "^3.3.3", "turbo": "^2.1.3" }, @@ -33,7 +33,7 @@ "engines": { "node": ">=18" }, - "packageManager": "bun@1.1.30", + "packageManager": "bun@1.1.31", "workspaces": [ "apps/*", "packages/*" diff --git a/packages/cqrs/src/decorators/constants.ts b/packages/cqrs/src/decorators/constants.ts index f8a8d61b5..50b82221e 100644 --- a/packages/cqrs/src/decorators/constants.ts +++ b/packages/cqrs/src/decorators/constants.ts @@ -3,3 +3,6 @@ export const COMMAND_HANDLER_METADATA = Symbol.for("COMMAND_HANDLER") export const QUERY_METADATA = Symbol.for("QUERY") export const QUERY_HANDLER_METADATA = Symbol.for("QUERY_HANDLER") + +export const EVENT_METADATA = Symbol.for("EVENT") +export const EVENT_HANDLER_METADATA = Symbol.for("EVENT_HANDLER") diff --git a/packages/cqrs/src/decorators/event-handler.decorator.ts b/packages/cqrs/src/decorators/event-handler.decorator.ts new file mode 100644 index 000000000..3f13bcb21 --- /dev/null +++ b/packages/cqrs/src/decorators/event-handler.decorator.ts @@ -0,0 +1,15 @@ +import "reflect-metadata" + +import type { BaseEvent } from "@undb/domain" +import type { Class } from "type-fest" +import { v4 } from "uuid" +import { EVENT_HANDLER_METADATA, EVENT_METADATA } from "./constants" + +export const eventHandler = (event: Class): ClassDecorator => { + return (target: object) => { + if (!Reflect.hasOwnMetadata(EVENT_METADATA, event)) { + Reflect.defineMetadata(EVENT_METADATA, { id: v4() }, event) + } + Reflect.defineMetadata(EVENT_HANDLER_METADATA, event, target) + } +} diff --git a/packages/cqrs/src/decorators/index.ts b/packages/cqrs/src/decorators/index.ts index 10a2c4b20..7c2343730 100644 --- a/packages/cqrs/src/decorators/index.ts +++ b/packages/cqrs/src/decorators/index.ts @@ -1,2 +1,3 @@ export * from "./command-handler.decorator" +export * from "./event-handler.decorator" export * from "./query-handler.decorator" diff --git a/packages/cqrs/src/default-event-publisher.ts b/packages/cqrs/src/default-event-publisher.ts new file mode 100644 index 000000000..b147bbd8b --- /dev/null +++ b/packages/cqrs/src/default-event-publisher.ts @@ -0,0 +1,14 @@ +import type { BaseEvent, IEventPublisher } from "@undb/domain" +import { Subject } from "rxjs" + +export class DefaultEventPubSub implements IEventPublisher { + constructor(public subject$: Subject) {} + + async publish(event: E): Promise { + this.subject$.next(event) + } + + async publishMany(events: E[]): Promise { + events.forEach((event) => this.subject$.next(event)) + } +} diff --git a/packages/cqrs/src/event-bus.ts b/packages/cqrs/src/event-bus.ts new file mode 100644 index 000000000..4426cbe91 --- /dev/null +++ b/packages/cqrs/src/event-bus.ts @@ -0,0 +1,74 @@ +import { container, singleton } from "@undb/di" +import type { BaseEvent, EventMetadata, IEventBus, IEventHandler, IEventPublisher } from "@undb/domain" +import { Subject } from "rxjs" +import type { Class } from "type-fest" +import { EVENT_HANDLER_METADATA, EVENT_METADATA } from "./decorators/constants" +import { DefaultEventPubSub } from "./default-event-publisher" +import { EventHandlerNotFoundException } from "./exceptions/event-handler-not-found.exception" +import { InvalidEventHandlerException } from "./exceptions/invalid-event-handler.exception" + +export type EventHandlerType = Class> + +@singleton() +export class EventBus implements IEventBus { + private subject = new Subject() + private readonly publisher: IEventPublisher = new DefaultEventPubSub(this.subject) + + #handlers = new Map>() + + async publish(event: TEvent): Promise { + console.log("publish event", event) + const eventId = this.getEventId(event) + const handler = this.#handlers.get(eventId) + if (!handler) { + const eventName = this.getEventName(event) + throw new EventHandlerNotFoundException(eventName) + } + this.publisher.publish(event) + return handler.handle(event) + } + + async publishMany(events: TEvent[]): Promise { + await Promise.all(events.map((event) => this.publish(event))) + } + + register(handlers: EventHandlerType[]) { + handlers.forEach((handler) => this.registerHandler(handler)) + } + + private bind(handler: IEventHandler, id: string) { + this.#handlers.set(id, handler) + } + + protected registerHandler(handler: EventHandlerType) { + const instance = container.resolve(handler) + if (!instance) { + return + } + const target = this.reflectEventId(handler) + if (!target) { + throw new InvalidEventHandlerException() + } + this.bind(instance as IEventHandler, target) + } + + private reflectEventId(handler: EventHandlerType): string | undefined { + const event: BaseEvent = Reflect.getMetadata(EVENT_HANDLER_METADATA, handler) + const eventMetadata: EventMetadata = Reflect.getMetadata(EVENT_METADATA, event) + return eventMetadata.id + } + + private getEventId(event: TEvent): string { + const { constructor: eventType } = Object.getPrototypeOf(event) + const eventMetadata: EventMetadata = Reflect.getMetadata(EVENT_METADATA, eventType) + if (!eventMetadata) { + throw new EventHandlerNotFoundException(eventType.name) + } + return eventMetadata.id + } + + private getEventName(event: TEvent): string { + const { constructor } = Object.getPrototypeOf(event) + return constructor.name as string + } +} diff --git a/packages/cqrs/src/exceptions/event-handler-not-found.exception.ts b/packages/cqrs/src/exceptions/event-handler-not-found.exception.ts new file mode 100644 index 000000000..395aef23f --- /dev/null +++ b/packages/cqrs/src/exceptions/event-handler-not-found.exception.ts @@ -0,0 +1,5 @@ +export class EventHandlerNotFoundException extends Error { + constructor(eventId: string) { + super(`The event handler for the "${eventId}" event was not found!`) + } +} diff --git a/packages/cqrs/src/exceptions/invalid-event-handler.exception.ts b/packages/cqrs/src/exceptions/invalid-event-handler.exception.ts new file mode 100644 index 000000000..4c0ef5527 --- /dev/null +++ b/packages/cqrs/src/exceptions/invalid-event-handler.exception.ts @@ -0,0 +1,5 @@ +export class InvalidEventHandlerException extends Error { + constructor() { + super(`Invalid event handler exception (missing @eventHandler() decorator?)`) + } +} diff --git a/packages/cqrs/src/index.ts b/packages/cqrs/src/index.ts index 70cafddb4..ef2155361 100644 --- a/packages/cqrs/src/index.ts +++ b/packages/cqrs/src/index.ts @@ -1,3 +1,4 @@ export * from "./command-bus" export * from "./decorators" +export * from "./event-bus" export * from "./query-bus" diff --git a/packages/domain/src/event-bus.ts b/packages/domain/src/event-bus.ts new file mode 100644 index 000000000..66f568720 --- /dev/null +++ b/packages/domain/src/event-bus.ts @@ -0,0 +1,6 @@ +import type { BaseEvent } from "./event.js" + +export interface IEventBus { + publish(event: TEvent): Promise + publishMany(events: TEvent[]): Promise +} diff --git a/packages/domain/src/event-metadata.ts b/packages/domain/src/event-metadata.ts new file mode 100644 index 000000000..d481a9574 --- /dev/null +++ b/packages/domain/src/event-metadata.ts @@ -0,0 +1,3 @@ +export interface EventMetadata { + id: string +} diff --git a/packages/domain/src/event-publisher.ts b/packages/domain/src/event-publisher.ts new file mode 100644 index 000000000..b56205080 --- /dev/null +++ b/packages/domain/src/event-publisher.ts @@ -0,0 +1,6 @@ +import type { BaseEvent } from "./event" + +export interface IEventPublisher { + publish(event: TEvent): Promise + publishMany(events: TEvent[]): Promise +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index d78a9a5c5..46e99f6a7 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -8,7 +8,10 @@ export * from "./command.js" export * from "./date.vo.js" export * from "./email.vo.js" export * from "./entity.base.js" +export * from "./event-bus.js" export * from "./event-handler.js" +export * from "./event-metadata.js" +export * from "./event-publisher.js" export * from "./event.js" export * from "./exception.base.js" export * from "./exceptions" diff --git a/packages/event-handlers/.gitignore b/packages/event-handlers/.gitignore new file mode 100644 index 000000000..9b1ee42e8 --- /dev/null +++ b/packages/event-handlers/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/packages/event-handlers/README.md b/packages/event-handlers/README.md new file mode 100644 index 000000000..6afd1e675 --- /dev/null +++ b/packages/event-handlers/README.md @@ -0,0 +1,15 @@ +# @undb/command-handlers + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` + +This project was created using `bun init` in bun v1.1.6. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. diff --git a/packages/event-handlers/package.json b/packages/event-handlers/package.json new file mode 100644 index 000000000..0864f8592 --- /dev/null +++ b/packages/event-handlers/package.json @@ -0,0 +1,25 @@ +{ + "name": "@undb/event-handlers", + "module": "src/index.ts", + "types": "src/index.d.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@undb/base": "workspace:*", + "@undb/commands": "workspace:*", + "@undb/context": "workspace:*", + "@undb/cqrs": "workspace:*", + "@undb/dashboard": "workspace:*", + "@undb/di": "workspace:*", + "@undb/logger": "workspace:*", + "@undb/openapi": "workspace:*", + "@undb/template": "workspace:*", + "@undb/user": "workspace:*", + "ts-pattern": "^5.5.0" + } +} diff --git a/packages/event-handlers/src/handlers/dashboard-on-field-deleted.event-handler.ts b/packages/event-handlers/src/handlers/dashboard-on-field-deleted.event-handler.ts new file mode 100644 index 000000000..ee53012a9 --- /dev/null +++ b/packages/event-handlers/src/handlers/dashboard-on-field-deleted.event-handler.ts @@ -0,0 +1,13 @@ +import { eventHandler } from "@undb/cqrs" +import type { IEventHandler } from "@undb/domain" +import { createLogger } from "@undb/logger" +import { FieldDeletedEvent } from "../../../table/src" + +@eventHandler(FieldDeletedEvent) +export class DashboardOnFieldDeletedEventHandle implements IEventHandler { + private readonly logger = createLogger(DashboardOnFieldDeletedEventHandle.name) + + handle(event: FieldDeletedEvent): void | Promise { + this.logger.debug(event) + } +} diff --git a/packages/event-handlers/src/handlers/index.ts b/packages/event-handlers/src/handlers/index.ts new file mode 100644 index 000000000..005dfd3e2 --- /dev/null +++ b/packages/event-handlers/src/handlers/index.ts @@ -0,0 +1,3 @@ +import { DashboardOnFieldDeletedEventHandle } from "./dashboard-on-field-deleted.event-handler" + +export const eventHandlers = [DashboardOnFieldDeletedEventHandle] diff --git a/packages/event-handlers/src/index.ts b/packages/event-handlers/src/index.ts new file mode 100644 index 000000000..a5e0863ed --- /dev/null +++ b/packages/event-handlers/src/index.ts @@ -0,0 +1 @@ +export * from "./registry" diff --git a/packages/event-handlers/src/registry.ts b/packages/event-handlers/src/registry.ts new file mode 100644 index 000000000..6c6904f9c --- /dev/null +++ b/packages/event-handlers/src/registry.ts @@ -0,0 +1,8 @@ +import { EventBus } from "@undb/cqrs" +import { container } from "@undb/di" +import { eventHandlers } from "./handlers" + +export const registerEvents = () => { + const eventBus = container.resolve(EventBus) + eventBus.register(eventHandlers) +} diff --git a/packages/event-handlers/tsconfig.json b/packages/event-handlers/tsconfig.json new file mode 100644 index 000000000..6979fd21c --- /dev/null +++ b/packages/event-handlers/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + + "experimentalDecorators": true, + "emitDecoratorMetadata": true + } +} diff --git a/packages/persistence/package.json b/packages/persistence/package.json index e9cb9854a..e1ee7d1db 100644 --- a/packages/persistence/package.json +++ b/packages/persistence/package.json @@ -14,6 +14,7 @@ "@libsql/client": "^0.14.0", "@libsql/kysely-libsql": "^0.4.1", "@undb/audit": "workspace:*", + "@undb/cqrs": "workspace:*", "@undb/authz": "workspace:*", "@undb/base": "workspace:*", "@undb/context": "workspace:*", diff --git a/packages/persistence/src/table/table.outbox-service.ts b/packages/persistence/src/table/table.outbox-service.ts index 437cc583e..3f6c3b9f5 100644 --- a/packages/persistence/src/table/table.outbox-service.ts +++ b/packages/persistence/src/table/table.outbox-service.ts @@ -1,5 +1,7 @@ import { injectContext, type IContext } from "@undb/context" -import { singleton } from "@undb/di" +import { EventBus } from "@undb/cqrs" +import { inject, singleton } from "@undb/di" +import type { IEventBus } from "@undb/domain" import type { ITableOutboxService, TableDo } from "@undb/table" import { getCurrentTransaction } from "../ctx" import { OutboxMapper } from "../outbox.mapper" @@ -9,12 +11,17 @@ export class TableOutboxService implements ITableOutboxService { constructor( @injectContext() private readonly context: IContext, + @inject(EventBus) + private readonly eventBus: IEventBus, ) {} async save(table: TableDo): Promise { const trx = getCurrentTransaction() const values = table.domainEvents.map((e) => OutboxMapper.fromEvent(e, this.context)) if (!values.length) return + await trx.insertInto("undb_outbox").values(values).execute() + this.eventBus.publishMany(table.domainEvents) + table.removeEvents(table.domainEvents) } @@ -22,7 +29,10 @@ export class TableOutboxService implements ITableOutboxService { const trx = getCurrentTransaction() const values = d.flatMap((table) => table.domainEvents.map((e) => OutboxMapper.fromEvent(e, this.context))) if (!values.length) return + await trx.insertInto("undb_outbox").values(values).execute() + this.eventBus.publishMany(d.flatMap((table) => table.domainEvents)) + d.forEach((table) => table.removeEvents(table.domainEvents)) } } diff --git a/packages/persistence/src/table/table.repository.ts b/packages/persistence/src/table/table.repository.ts index d934a7814..6d8a47518 100644 --- a/packages/persistence/src/table/table.repository.ts +++ b/packages/persistence/src/table/table.repository.ts @@ -1,7 +1,7 @@ -import { injectContext,type IContext } from "@undb/context" -import { executionContext,getCurrentSpaceId } from "@undb/context/server" -import { inject,singleton } from "@undb/di" -import { None,Option,Some } from "@undb/domain" +import { injectContext, type IContext } from "@undb/context" +import { executionContext, getCurrentSpaceId } from "@undb/context/server" +import { inject, singleton } from "@undb/di" +import { None, Option, Some } from "@undb/domain" import { TableComositeSpecification, TableIdSpecification, @@ -13,8 +13,8 @@ import { type TableId, } from "@undb/table" import { getCurrentTransaction } from "../ctx" -import type { InsertTable,InsertTableIdMapping } from "../db" -import { json,type IQueryBuilder } from "../qb" +import type { InsertTable, InsertTableIdMapping } from "../db" +import { json, type IQueryBuilder } from "../qb" import { injectQueryBuilder } from "../qb.provider" import { UnderlyingTableService } from "../underlying/underlying-table.service" import { TableDbQuerySpecHandler } from "./table-db.query-spec-handler"