@elysiumjs/core 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import './macros';
16
16
 
17
- export { type Elysia, t } from 'elysia';
17
+ export { type Elysia, type TSchema, type Static, t } from 'elysia';
18
18
  export { type AppContext, type ElysiumPlugin, Application } from './src/app';
19
19
  export { Cache } from './src/cache';
20
20
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elysiumjs/core",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "A super-charged web framework for Bun, built on top of ElysiaJS.",
6
6
  "private": false,
@@ -29,7 +29,7 @@
29
29
  "@elysiajs/swagger": "^1.2.2",
30
30
  "cache-manager": "^6.4.2",
31
31
  "cacheable": "^1.8.10",
32
- "drizzle-orm": "^0.43.1",
32
+ "drizzle-orm": "0.43.1-4aa795a",
33
33
  "elysia": "latest",
34
34
  "radash": "^12.1.0",
35
35
  "reflect-metadata": "^0.2.2",
package/src/app.ts CHANGED
@@ -47,6 +47,9 @@ export type ElysiumPlugin = (app: Application) => Promise<AnyElysia>;
47
47
  * @author Axel Nana <axel.nana@workbud.com>
48
48
  */
49
49
  export type AppProps = {
50
+ // Allows to pass custom options to the application properties.
51
+ [key: string]: any;
52
+
50
53
  /**
51
54
  * Enables or disables debug mode.
52
55
  */
@@ -349,6 +352,15 @@ export abstract class Application extends InteractsWithConsole {
349
352
  return process.exit(0);
350
353
  }
351
354
 
355
+ /**
356
+ * Retrieves the configuration value for the given key.
357
+ * @param key The key to retrieve the configuration value for.
358
+ * @returns The configuration value for the given key, or `null` if not found.
359
+ */
360
+ public getConfig<T>(key: keyof AppProps): T | null {
361
+ return Reflect.getMetadata(Symbols.app, this.constructor)?.[key] ?? null;
362
+ }
363
+
352
364
  private async commandWork(argv: string[]) {
353
365
  this.info('Starting worker process...');
354
366
 
@@ -480,13 +492,18 @@ export abstract class Application extends InteractsWithConsole {
480
492
  .onError({ as: 'global' }, async (e) => {
481
493
  if (await this.onError(e)) {
482
494
  Event.emit('elysium:error', e);
495
+ this.trace({
496
+ name: 'HttpError',
497
+ message: 'Unexpected error while handling HTTP request',
498
+ // @ts-expect-error The stack property may not exist in the error.
499
+ stack: e.error.stack,
500
+ ...e.error
501
+ });
483
502
 
484
503
  let data = {};
485
504
 
486
505
  if (typeof e.code === 'number') {
487
- data = {
488
- message: e.error.response
489
- };
506
+ return e.error.response;
490
507
  } else {
491
508
  switch (e.code) {
492
509
  case 'VALIDATION': {
@@ -505,54 +522,8 @@ export abstract class Application extends InteractsWithConsole {
505
522
  }
506
523
  }
507
524
 
508
- const type = (() => {
509
- if (typeof e.code === 'string') return e.code;
510
- else {
511
- switch (e.code) {
512
- case 500:
513
- return 'INTERNAL_SERVER_ERROR';
514
- case 400:
515
- return 'BAD_REQUEST';
516
- case 401:
517
- return 'UNAUTHORIZED';
518
- case 403:
519
- return 'FORBIDDEN';
520
- case 404:
521
- return 'NOT_FOUND';
522
- case 405:
523
- return 'METHOD_NOT_ALLOWED';
524
- case 409:
525
- return 'CONFLICT';
526
- case 429:
527
- return 'TOO_MANY_REQUESTS';
528
- case 422:
529
- return 'UNPROCESSABLE_ENTITY';
530
- case 426:
531
- return 'UPGRADE_REQUIRED';
532
- case 428:
533
- return 'PRECONDITION_REQUIRED';
534
- case 431:
535
- return 'REQUEST_HEADER_FIELDS_TOO_LARGE';
536
- case 451:
537
- return 'UNAVAILABLE_FOR_LEGAL_REASONS';
538
- case 501:
539
- return 'NOT_IMPLEMENTED';
540
- case 502:
541
- return 'BAD_GATEWAY';
542
- case 503:
543
- return 'SERVICE_UNAVAILABLE';
544
- case 504:
545
- return 'GATEWAY_TIMEOUT';
546
- case 505:
547
- return 'HTTP_VERSION_NOT_SUPPORTED';
548
- }
549
- }
550
-
551
- return 'UNKNOWN';
552
- })();
553
-
554
525
  return {
555
- type,
526
+ type: e.code,
556
527
  data
557
528
  };
558
529
  }
package/src/cache.ts CHANGED
@@ -25,7 +25,7 @@ import { KeyvRedis } from './redis';
25
25
  * Interface for a cache storage.
26
26
  * @author Axel Nana <axel.nana@workbud.com>
27
27
  */
28
- interface CacheInterface {
28
+ export interface CacheInterface {
29
29
  /**
30
30
  * Retrieves the cached data for a given key.
31
31
  * @typeParam T The type of the cached data.
package/src/console.ts CHANGED
@@ -142,22 +142,26 @@ export class InteractsWithConsole {
142
142
  // Display the title if provided
143
143
  if (title) {
144
144
  this.title(title);
145
+ } else {
146
+ this.newLine();
145
147
  }
146
148
 
147
149
  // Display the error name and message in red
148
150
  this.write(this.format(`${errorName}: ${errorMessage}`, ConsoleFormat.RED));
149
151
 
150
- // Display the stack trace in gray, each line prefixed by a tab for readability
151
- this.write(this.format('Stack Trace:', ConsoleFormat.BOLD));
152
- this.write(
153
- this.format(
154
- errorStack
155
- .split('\n')
156
- .map((line) => `\t${line}`)
157
- .join('\n'),
158
- ConsoleFormat.GRAY
159
- )
160
- );
152
+ if (error.stack) {
153
+ // Display the stack trace in gray, each line prefixed by a tab for readability
154
+ this.write(this.format('Stack Trace:', ConsoleFormat.BOLD));
155
+ this.write(
156
+ this.format(
157
+ errorStack
158
+ .split('\n')
159
+ .map((line) => `\t${line}`)
160
+ .join('\n'),
161
+ ConsoleFormat.GRAY
162
+ )
163
+ );
164
+ }
161
165
 
162
166
  // Display any other custom properties
163
167
  const customProps = omit(error, ['name', 'message', 'stack']);
@@ -169,6 +173,8 @@ export class InteractsWithConsole {
169
173
  this.write(`\t${prop}: ${JSON.stringify(value)}`);
170
174
  });
171
175
  }
176
+
177
+ this.newLine();
172
178
  }
173
179
 
174
180
  /**
package/src/database.ts CHANGED
@@ -14,9 +14,14 @@
14
14
 
15
15
  import type { DrizzleConfig } from 'drizzle-orm';
16
16
  import type { BunSQLDatabase } from 'drizzle-orm/bun-sql';
17
+ import type { CacheConfig } from 'drizzle-orm/cache/core/types';
18
+ import type { CacheInterface } from './cache';
17
19
 
20
+ import { getTableName, is, Table } from 'drizzle-orm';
18
21
  import { drizzle } from 'drizzle-orm/bun-sql';
22
+ import { Cache as DrizzleCache } from 'drizzle-orm/cache/core';
19
23
 
24
+ import { Cache } from './cache';
20
25
  import { Service } from './service';
21
26
 
22
27
  /**
@@ -25,7 +30,7 @@ import { Service } from './service';
25
30
  */
26
31
  export type DatabaseConnectionProps<
27
32
  TSchema extends Record<string, unknown> = Record<string, never>
28
- > = DrizzleConfig<TSchema> &
33
+ > = Omit<DrizzleConfig<TSchema>, 'cache'> &
29
34
  (
30
35
  | {
31
36
  connection:
@@ -37,7 +42,40 @@ export type DatabaseConnectionProps<
37
42
  | {
38
43
  client: Bun.SQL;
39
44
  }
40
- );
45
+ ) & {
46
+ /**
47
+ * Configures caching behavior for the database connection.
48
+ * Set it to `true` to enable caching with default configurations.
49
+ */
50
+ cache?:
51
+ | true
52
+ | {
53
+ /**
54
+ * Whether to enable caching.
55
+ */
56
+ enabled?: boolean;
57
+
58
+ /**
59
+ * The default TTL for cached data, in seconds.
60
+ * @default 1
61
+ */
62
+ ttl?: number;
63
+
64
+ /**
65
+ * The caching strategy.
66
+ * Set it to `all` to cache all queries automatically.
67
+ * Set it to `explicit` to cache queries manually.
68
+ * @default 'explicit'
69
+ */
70
+ strategy?: 'explicit' | 'all';
71
+
72
+ /**
73
+ * The storage engine to use.
74
+ * @default 'redis'
75
+ */
76
+ storage?: 'redis' | 'memory';
77
+ };
78
+ };
41
79
 
42
80
  /**
43
81
  * A database connection.
@@ -47,6 +85,83 @@ export type DatabaseConnection = BunSQLDatabase<Record<string, never>> & {
47
85
  $client: Bun.SQL;
48
86
  };
49
87
 
88
+ /**
89
+ * Database cache implementation for Drizzle.
90
+ * @author Axel Nana <axel.nana@workbud.com>
91
+ */
92
+ export class DatabaseCache extends DrizzleCache {
93
+ #cache: Omit<CacheInterface, 'tags'>;
94
+
95
+ private usedTablesPerKey: Record<string, string[]> = {};
96
+
97
+ constructor(
98
+ private readonly cacheStrategy: 'explicit' | 'all' = 'explicit',
99
+ private readonly globalTtl: number = 1,
100
+ storageEngine: 'redis' | 'memory' = 'redis'
101
+ ) {
102
+ super();
103
+ this.#cache = Cache[storageEngine].tags('database');
104
+ }
105
+
106
+ public override strategy(): 'explicit' | 'all' {
107
+ return this.cacheStrategy;
108
+ }
109
+
110
+ public override async get(key: string): Promise<any[] | undefined> {
111
+ const res = (await this.#cache.get<any[]>(key)) ?? undefined;
112
+ return res;
113
+ }
114
+
115
+ public override async put(
116
+ key: string,
117
+ response: any,
118
+ tables: string[],
119
+ isTag: boolean,
120
+ config?: CacheConfig
121
+ ): Promise<void> {
122
+ console.log({ key, response, tables, isTag, config });
123
+ await this.#cache.set(key, response, (config?.ex ?? this.globalTtl) * 1000);
124
+ for (const table of tables) {
125
+ const keys = this.usedTablesPerKey[table];
126
+ if (keys === undefined) {
127
+ this.usedTablesPerKey[table] = [key];
128
+ } else {
129
+ keys.push(key);
130
+ }
131
+ }
132
+ }
133
+
134
+ public override async onMutate(params: {
135
+ tags: string | string[];
136
+ tables: string | string[] | Table<any> | Table<any>[];
137
+ }): Promise<void> {
138
+ const tagsArray = params.tags ? (Array.isArray(params.tags) ? params.tags : [params.tags]) : [];
139
+ const tablesArray = params.tables
140
+ ? Array.isArray(params.tables)
141
+ ? params.tables
142
+ : [params.tables]
143
+ : [];
144
+
145
+ const keysToDelete = new Set<string>();
146
+
147
+ for (const table of tablesArray) {
148
+ const tableName = is(table, Table) ? getTableName(table) : (table as string);
149
+ const keys = this.usedTablesPerKey[tableName] ?? [];
150
+ for (const key of keys) keysToDelete.add(key);
151
+ }
152
+
153
+ if (keysToDelete.size > 0 || tagsArray.length > 0) {
154
+ await this.#cache.mdel(tagsArray);
155
+ await this.#cache.mdel([...keysToDelete]);
156
+
157
+ for (const table of tablesArray) {
158
+ const tableName = is(table, Table) ? getTableName(table) : (table as string);
159
+ this.usedTablesPerKey[tableName] = [];
160
+ }
161
+ }
162
+ }
163
+ }
164
+
50
165
  export namespace Database {
51
166
  /**
52
167
  * Creates a service name for a database connection.
@@ -94,7 +209,20 @@ export namespace Database {
94
209
  process.exit(1);
95
210
  }
96
211
 
97
- return Service.instance(getConnectionName(name), drizzle(config));
212
+ return Service.instance(
213
+ getConnectionName(name),
214
+ drizzle({
215
+ ...config,
216
+ cache:
217
+ config.cache === undefined
218
+ ? undefined
219
+ : config.cache === true
220
+ ? new DatabaseCache()
221
+ : config.cache.enabled
222
+ ? new DatabaseCache(config.cache.strategy, config.cache.ttl, config.cache.storage)
223
+ : undefined
224
+ })
225
+ );
98
226
  };
99
227
 
100
228
  /**
package/src/event.ts CHANGED
@@ -25,6 +25,8 @@ class EventBus extends EventEmitter {
25
25
 
26
26
  private constructor() {
27
27
  super();
28
+
29
+ this.on('error', (error) => this.emitError(error));
28
30
  }
29
31
 
30
32
  public emitError(error: unknown) {
package/src/http.ts CHANGED
@@ -33,6 +33,32 @@ import { nextTick, Symbols } from './utils';
33
33
  */
34
34
  export type Route = `/${string}`;
35
35
 
36
+ /**
37
+ * A class for validating data against a schema.
38
+ * @author Axel Nana <axel.nana@workbud.com>
39
+ */
40
+ export type ValidatorClass<T extends TSchema = TSchema> = {
41
+ /**
42
+ * The validation schema. This schema is automatically checked against the input data during
43
+ * the validation stage of the HTTP request.
44
+ */
45
+ readonly schema: T;
46
+
47
+ /**
48
+ * Whether the request should be authorized before validation. If the request is not authorized,
49
+ * the validation will be skipped.
50
+ */
51
+ authorize(ctx: Context<T['static']>): Promise<boolean>;
52
+
53
+ /**
54
+ * Processes custom validation logic. This method is called after the validation schema is checked
55
+ * against the input data. So it's safe to assume that the schema is valid.
56
+ * @param data The input data to validate.
57
+ * @returns Whether the input data is valid.
58
+ */
59
+ validate(ctx: Context<T['static']>): Promise<boolean>;
60
+ };
61
+
36
62
  /**
37
63
  * A function that handles an HTTP request.
38
64
  * @author Axel Nana <axel.nana@workbud.com>
@@ -62,12 +88,12 @@ type HttpRequestHandlerMetadata = {
62
88
  /**
63
89
  * The response schema.
64
90
  */
65
- response?: TSchema;
91
+ response?: TSchema | Record<number, TSchema>;
66
92
 
67
93
  /**
68
94
  * The request body schema.
69
95
  */
70
- body?: { schema?: TSchema; index: number };
96
+ body?: { schema?: TSchema; validator?: ValidatorClass; index: number };
71
97
 
72
98
  /**
73
99
  * The request query schema.
@@ -164,8 +190,14 @@ export type Singleton<TController = unknown, TModule extends Module = Module> =
164
190
  * The Elysia context with the controller injected.
165
191
  * @author Axel Nana <axel.nana@workbud.com>
166
192
  */
167
- export type Context<TController = unknown, TModule extends Module = Module> = ElysiaContext<
168
- {},
193
+ export type Context<
194
+ TBody = unknown,
195
+ TController = unknown,
196
+ TModule extends Module = Module
197
+ > = ElysiaContext<
198
+ {
199
+ body: TBody;
200
+ },
169
201
  Singleton<TController, TModule>
170
202
  >;
171
203
 
@@ -271,7 +303,7 @@ export type RequestHandlerDecoratorProps = {
271
303
  /**
272
304
  * The schema of the response body.
273
305
  */
274
- response?: TSchema;
306
+ response?: TSchema | Record<number, TSchema>;
275
307
 
276
308
  /**
277
309
  * The operation ID for the handler.
@@ -373,6 +405,8 @@ export namespace Http {
373
405
  const isGenerator = route.handler.constructor.name.includes('GeneratorFunction');
374
406
  const isSSE = route.method === 'elysium:SSE';
375
407
  const isTransactional = route.transactional ?? props.transactional ?? false;
408
+ const isValidator =
409
+ route.body && route.body.schema === undefined && route.body.validator !== undefined;
376
410
 
377
411
  route.method = isSSE ? 'GET' : route.method;
378
412
 
@@ -439,6 +473,17 @@ export namespace Http {
439
473
  };
440
474
  }
441
475
 
476
+ if (isValidator) {
477
+ return async function (c: Context) {
478
+ if (await route.body!.validator!.validate(c)) {
479
+ const handler = initTransactedHandler(c);
480
+ return await handler(...(await getParameters(c)));
481
+ }
482
+
483
+ throw c.error(400, 'Invalid request body');
484
+ };
485
+ }
486
+
442
487
  return async function (c: Context) {
443
488
  const handler = initTransactedHandler(c);
444
489
  return await handler(...(await getParameters(c)));
@@ -456,6 +501,13 @@ export namespace Http {
456
501
  app.route(route.method, route.path, getHandler(), {
457
502
  // @ts-ignore
458
503
  config: {},
504
+ async transform(c) {
505
+ if (isValidator && !(await route.body!.validator!.authorize(c as Context))) {
506
+ throw c.error(401, 'Unauthorized');
507
+ }
508
+
509
+ return executeMiddlewareChain(mi, c, 'onTransform');
510
+ },
459
511
  beforeHandle(c) {
460
512
  return executeMiddlewareChain(mi, c, 'onBeforeHandle');
461
513
  },
@@ -470,7 +522,7 @@ export namespace Http {
470
522
  description: route.description,
471
523
  operationId: route.operationId
472
524
  },
473
- body: route.body?.schema,
525
+ body: isValidator ? route.body?.validator?.schema : route.body?.schema,
474
526
  response: route.response,
475
527
  params: isEmpty(params) ? undefined : t.Object(params)
476
528
  });
@@ -647,9 +699,23 @@ export namespace Http {
647
699
  * @author Axel Nana <axel.nana@workbud.com>
648
700
  * @param schema The schema of the request body.
649
701
  */
650
- export const body = (schema?: TSchema): ParameterDecorator => {
702
+ export const body = (schema?: TSchema | ValidatorClass): ParameterDecorator => {
651
703
  return function (target, propertyKey, parameterIndex) {
652
- Reflect.defineMetadata('http:body', { schema, index: parameterIndex }, target, propertyKey!);
704
+ if (typeof schema === 'function') {
705
+ Reflect.defineMetadata(
706
+ 'http:body',
707
+ { validator: schema, index: parameterIndex },
708
+ target,
709
+ propertyKey!
710
+ );
711
+ } else {
712
+ Reflect.defineMetadata(
713
+ 'http:body',
714
+ { schema, index: parameterIndex },
715
+ target,
716
+ propertyKey!
717
+ );
718
+ }
653
719
  };
654
720
  };
655
721
 
package/src/middleware.ts CHANGED
@@ -61,6 +61,14 @@ export const executeMiddlewareChain = (
61
61
  export const applyMiddlewares = (middlewares: Class<Middleware>[], plugin: AnyElysia) => {
62
62
  const mi = middlewares.map((middleware) => Service.make<Middleware>(middleware));
63
63
 
64
+ plugin.onRequest((c) => {
65
+ return executeMiddlewareChain(mi, c, 'onRequest');
66
+ });
67
+
68
+ plugin.onTransform((c) => {
69
+ return executeMiddlewareChain(mi, c, 'onTransform');
70
+ });
71
+
64
72
  plugin.onBeforeHandle((c) => {
65
73
  return executeMiddlewareChain(mi, c, 'onBeforeHandle');
66
74
  });
@@ -77,8 +85,9 @@ export const applyMiddlewares = (middlewares: Class<Middleware>[], plugin: AnyEl
77
85
  /**
78
86
  * Base class for middleware.
79
87
  * @author Axel Nana <axel.nana@workbud.com>
88
+ * @template TGuards The list of guards for the middleware.
80
89
  */
81
- export abstract class Middleware {
90
+ export abstract class Middleware<TGuards extends string[] = []> {
82
91
  /**
83
92
  * Registers a list of middlewares to be applied on a controller or endpoint.
84
93
  * @author Axel Nana <axel.nana@workbud.com>
@@ -117,10 +126,24 @@ export abstract class Middleware {
117
126
  /**
118
127
  * Gets the list of guards for the middleware.
119
128
  */
120
- public get guards(): string[] {
129
+ public get guards(): TGuards {
121
130
  return Reflect.getMetadata(Symbols.middlewareGuards, this.constructor) ?? [];
122
131
  }
123
132
 
133
+ /**
134
+ * Called first before everything else. This middleware hook works only when used on
135
+ * a controller, a module, or the application.
136
+ * @param ctx The request context.
137
+ */
138
+ public onRequest(ctx: Context): any {}
139
+
140
+ /**
141
+ * Called before validation, but after the request has been parsed.
142
+ * Use this method to modify the request inputs before validation.
143
+ * @param ctx The request context.
144
+ */
145
+ public onTransform(ctx: Context): any {}
146
+
124
147
  /**
125
148
  * Called after the request has been handled, but before the response is sent.
126
149
  * @param ctx The request context.
package/src/model.ts CHANGED
@@ -12,7 +12,14 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
 
15
- import type { PgColumn, PgColumnBuilderBase, PgSchema, PgTable } from 'drizzle-orm/pg-core';
15
+ import type { BuildExtraConfigColumns } from 'drizzle-orm';
16
+ import type {
17
+ PgColumn,
18
+ PgColumnBuilderBase,
19
+ PgSchema,
20
+ PgTable,
21
+ PgTableExtraConfigValue
22
+ } from 'drizzle-orm/pg-core';
16
23
  import type { TSchema } from 'elysia';
17
24
 
18
25
  import { getTableConfig, pgSchema, pgTable } from 'drizzle-orm/pg-core';
@@ -65,7 +72,9 @@ const parseTypes = (element: PgColumn) => {
65
72
  let type = (() => {
66
73
  switch (element.dataType) {
67
74
  case 'string':
68
- return t.String();
75
+ return t.String({
76
+ format: element.columnType === 'PgUUID' ? 'uuid' : undefined
77
+ });
69
78
  case 'number':
70
79
  return t.Number();
71
80
  case 'boolean':
@@ -89,6 +98,8 @@ const parseTypes = (element: PgColumn) => {
89
98
 
90
99
  if (!element.notNull) {
91
100
  type = t.Optional(type);
101
+ } else {
102
+ type = t.Required(type);
92
103
  }
93
104
 
94
105
  return type;
@@ -103,7 +114,7 @@ const parseTypes = (element: PgColumn) => {
103
114
  export type ModelClass<
104
115
  TTableName extends string,
105
116
  TColumnsMap extends Record<string, PgColumnBuilderBase>,
106
- TTable extends PgTable = ReturnType<typeof pgTable<string, TColumnsMap>>
117
+ TTable extends PgTable = ReturnType<typeof pgTable<TTableName, TColumnsMap>>
107
118
  > = {
108
119
  /**
109
120
  * The data type returned by the select queries.
@@ -136,10 +147,17 @@ export type ModelClass<
136
147
  */
137
148
  readonly columns: TColumnsMap;
138
149
 
150
+ /**
151
+ * The extra configuration for the table.
152
+ */
153
+ readonly extraConfig?: (
154
+ self: BuildExtraConfigColumns<TTableName, TColumnsMap, 'pg'>
155
+ ) => PgTableExtraConfigValue[];
156
+
139
157
  /**
140
158
  * The validation schema for creating records.
141
159
  */
142
- readonly createSchema: TSchema;
160
+ readonly insertSchema: TSchema;
143
161
 
144
162
  /**
145
163
  * The validation schema for updating records.
@@ -179,9 +197,12 @@ export const Model = <
179
197
  TColumnsMap extends Record<string, PgColumnBuilderBase>
180
198
  >(
181
199
  tableName: TTableName,
182
- columns: TColumnsMap
200
+ columns: TColumnsMap,
201
+ extraConfig?: (
202
+ self: BuildExtraConfigColumns<TTableName, TColumnsMap, 'pg'>
203
+ ) => PgTableExtraConfigValue[]
183
204
  ) => {
184
- const table = pgTable(tableName, columns);
205
+ const table = pgTable(tableName, columns, extraConfig);
185
206
 
186
207
  class M {
187
208
  /**
@@ -227,10 +248,15 @@ export const Model = <
227
248
  */
228
249
  public static readonly columns: TColumnsMap = columns;
229
250
 
251
+ /**
252
+ * The extra configuration for the table.
253
+ */
254
+ public static readonly extraConfig = extraConfig;
255
+
230
256
  /**
231
257
  * The validation schema for creating records.
232
258
  */
233
- public static readonly createSchema = createSchemaFromDrizzle(table, { mode: 'create' });
259
+ public static readonly insertSchema = createSchemaFromDrizzle(table, { mode: 'create' });
234
260
 
235
261
  /**
236
262
  * The validation schema for updating records.
package/src/repository.ts CHANGED
@@ -20,7 +20,6 @@ import type { DatabaseConnection } from './database';
20
20
  import type { ModelClass } from './model';
21
21
 
22
22
  import { eq } from 'drizzle-orm';
23
- import { pgTable } from 'drizzle-orm/pg-core';
24
23
 
25
24
  import { Application } from './app';
26
25
  import { Database } from './database';
@@ -39,9 +38,13 @@ export type IdType = number | string;
39
38
  * @template TColumnsMap The table columns config.
40
39
  */
41
40
  export interface RepositoryInterface<
42
- TModel extends ModelClass<TColumnsMap>,
41
+ TModel extends ModelClass<TTableName, TColumnsMap>,
43
42
  TId extends IdType = string,
43
+ TTableName extends string = TModel extends ModelClass<infer TTableName, infer _>
44
+ ? TTableName
45
+ : string,
44
46
  TColumnsMap extends Record<string, PgColumnBuilderBase> = TModel extends ModelClass<
47
+ TTableName,
45
48
  infer TColumnsMap
46
49
  >
47
50
  ? TColumnsMap
@@ -104,14 +107,18 @@ export interface RepositoryInterface<
104
107
  * @template TColumnsMap The table columns config.
105
108
  */
106
109
  export type RepositoryClass<
107
- TModel extends ModelClass<TColumnsMap>,
110
+ TModel extends ModelClass<TTableName, TColumnsMap>,
108
111
  TId extends IdType = string,
112
+ TTableName extends string = TModel extends ModelClass<infer TTableName, infer _>
113
+ ? TTableName
114
+ : string,
109
115
  TColumnsMap extends Record<string, PgColumnBuilderBase> = TModel extends ModelClass<
116
+ TTableName,
110
117
  infer TColumnsMap
111
118
  >
112
119
  ? TColumnsMap
113
120
  : Record<string, PgColumnBuilderBase>
114
- > = Class<RepositoryInterface<TModel, TId, TColumnsMap>> & {
121
+ > = Class<RepositoryInterface<TModel, TId, TTableName, TColumnsMap>> & {
115
122
  /**
116
123
  * The drizzle's table schema wrapped by the repository.
117
124
  */
@@ -134,9 +141,13 @@ export type RepositoryClass<
134
141
  * @template model The model class wrapped by the repository.
135
142
  */
136
143
  export const Repository = <
137
- TModel extends ModelClass<TColumnsMap>,
144
+ TModel extends ModelClass<TTableName, TColumnsMap>,
138
145
  TId extends IdType = string,
146
+ TTableName extends string = TModel extends ModelClass<infer TTableName, infer _>
147
+ ? TTableName
148
+ : string,
139
149
  TColumnsMap extends Record<string, PgColumnBuilderBase> = TModel extends ModelClass<
150
+ TTableName,
140
151
  infer TColumnsMap
141
152
  >
142
153
  ? TColumnsMap
@@ -148,9 +159,7 @@ export const Repository = <
148
159
  type TInsert = TModel['$inferInsert'];
149
160
  type TUpdate = TModel['$inferUpdate'];
150
161
 
151
- const table = pgTable(model.tableName, model.columns);
152
-
153
- class R implements RepositoryInterface<TModel, TId, TColumnsMap> {
162
+ class R implements RepositoryInterface<TModel, TId, TTableName, TColumnsMap> {
154
163
  /**
155
164
  * The drizzle's table schema wrapped by this repository.
156
165
  */
@@ -167,7 +176,8 @@ export const Repository = <
167
176
  * The database connection used by this repository.
168
177
  */
169
178
  public get db(): DatabaseConnection {
170
- const connection = (this.constructor as RepositoryClass<TModel, TId, TColumnsMap>).connection;
179
+ const connection = (this.constructor as RepositoryClass<TModel, TId, TTableName, TColumnsMap>)
180
+ .connection;
171
181
  let db: DatabaseConnection | null = null;
172
182
 
173
183
  if (connection === 'default') {
@@ -185,6 +195,25 @@ export const Repository = <
185
195
  return await this.db.select().from(model.table);
186
196
  }
187
197
 
198
+ /**
199
+ * Get a paginated subset of the records in the database.
200
+ * @param page The page number to retrieve.
201
+ * @param count The number of records to retrieve per page.
202
+ * @returns A paginated response containing the records and the total number of records.
203
+ */
204
+ public async paginate(
205
+ count: number,
206
+ page: number = 1
207
+ ): Promise<{ page: number; data: TSelect[]; total: number }> {
208
+ const total = await this.db.$count(model.table);
209
+ const offset = Math.max(page - 1, 0) * count;
210
+ return {
211
+ page,
212
+ total,
213
+ data: await this.db.select().from(model.table).offset(offset).limit(count)
214
+ };
215
+ }
216
+
188
217
  /**
189
218
  * Retrieves a record by its id.
190
219
  * @param id The id of the record to retrieve.
@@ -195,6 +224,20 @@ export const Repository = <
195
224
  return (row ?? null) as TSelect | null;
196
225
  }
197
226
 
227
+ /**
228
+ * Finds the first record with a column matching the given value.
229
+ * @param column The column to filter by.
230
+ * @param value The value the column should match.
231
+ * @returns The first record with a column matching the given value.
232
+ */
233
+ public async findBy<TKey extends keyof TSelect>(
234
+ column: TKey,
235
+ value: TSelect[TKey]
236
+ ): Promise<TSelect | null> {
237
+ const [row] = await this.db.select().from(model.table).where(eq(model.table[column], value));
238
+ return (row ?? null) as TSelect | null;
239
+ }
240
+
198
241
  /**
199
242
  * Inserts a new record in the database.
200
243
  * @param data The data to insert.
package/src/service.ts CHANGED
@@ -169,15 +169,17 @@ export namespace Service {
169
169
  /**
170
170
  * Resolves a registered service and set it as a parameter value.
171
171
  * @author Axel Nana <axel.nana@workbud.com>
172
- * @param name An optional name for the service. If not set, the name of the parameter's type is used instead.
172
+ * @param service An optional name for the service. If not set, the name of the parameter's type is used instead.
173
173
  */
174
- export const inject = (name?: string): ParameterDecorator => {
174
+ export const inject = (service?: string | ServiceClass): ParameterDecorator => {
175
175
  return function (target, propertyKey, parameterIndex) {
176
176
  const services = Reflect.getMetadata(Symbols.services, target, propertyKey!) ?? [];
177
- const types = Reflect.getMetadata('design:paramtypes', target, propertyKey!) ?? [];
177
+ const types: Function[] =
178
+ Reflect.getMetadata('design:paramtypes', target, propertyKey!) ?? [];
179
+ const name = isString(service) ? service : (service?.name ?? types[parameterIndex].name);
178
180
 
179
181
  services.push({
180
- name: name ?? types[parameterIndex].name,
182
+ name,
181
183
  index: parameterIndex
182
184
  });
183
185
 
@@ -223,7 +225,7 @@ export namespace Service {
223
225
  if (s === null) {
224
226
  // TODO: Use the logger service here
225
227
  console.error(
226
- `Cannot inject a service. No service was registered with the name: ${dependency.name}`
228
+ `Cannot inject service ${dependency.name}. No service was registered with the name: ${dependency.name}`
227
229
  );
228
230
  process.exit(1);
229
231
  }
package/src/wamp.ts CHANGED
@@ -90,22 +90,32 @@ export type WampProps = {
90
90
  * The interval to wait between retries when connecting to the WAMP server.
91
91
  */
92
92
  retryInterval?: number;
93
+
94
+ /**
95
+ * The authentication ID to use in challenges.
96
+ */
97
+ authid?: string;
98
+
99
+ /**
100
+ * An array of supported authentication methods.
101
+ */
102
+ authmethods?: string[];
93
103
  };
94
104
 
95
105
  /**
96
106
  * Data sent to a WAMP RPC.
97
107
  * @author Axel Nana <axel.nana@workbud.com>
98
108
  */
99
- export type WampRegistrationHandlerArgs = {
109
+ export type WampRegistrationHandlerArgs<TList = any[], TDict = any> = {
100
110
  /**
101
111
  * The arguments passed to the RPC or subscription.
102
112
  */
103
- argsList: any[];
113
+ argsList: TList;
104
114
 
105
115
  /**
106
116
  * The keyword arguments passed to the RPC or subscription.
107
117
  */
108
- argsDict: any;
118
+ argsDict: TDict;
109
119
 
110
120
  /**
111
121
  * The WAMP details object.
@@ -213,6 +223,7 @@ export namespace Wamp {
213
223
  const w = new Wampy(options.url, {
214
224
  ws: WampWebsocket,
215
225
  ...omit(options, ['url']),
226
+ onChallenge: metadata.challenge?.bind(controller),
216
227
  onClose: metadata.close?.bind(controller),
217
228
  onError() {
218
229
  metadata.error?.call(controller);
@@ -316,11 +327,28 @@ export namespace Wamp {
316
327
  };
317
328
 
318
329
  /**
319
- * Marks a method as the WAMP "open" event handler.
330
+ * Marks a method as the WAMP "challenge" event handler.
331
+ *
332
+ * This decorator should be used on a WAMP controller method. Only one "challenge" event handler
333
+ * can be defined per WAMP controller.
334
+ *
320
335
  * @author Axel Nana <axel.nana@workbud.com>
336
+ */
337
+ export const onChallenge = (): MethodDecorator => {
338
+ return function (target, _propertyKey, descriptor) {
339
+ const metadata = Reflect.getMetadata(Symbols.wamp, target.constructor) ?? {};
340
+ metadata.challenge = descriptor.value;
341
+ Reflect.defineMetadata(Symbols.wamp, metadata, target.constructor);
342
+ };
343
+ };
344
+
345
+ /**
346
+ * Marks a method as the WAMP "open" event handler.
321
347
  *
322
348
  * This decorator should be used on a WAMP controller method. Only one "open" event handler
323
349
  * can be defined per WAMP controller.
350
+ *
351
+ * @author Axel Nana <axel.nana@workbud.com>
324
352
  */
325
353
  export const onOpen = (): MethodDecorator => {
326
354
  return function (target, _propertyKey, descriptor) {
@@ -332,10 +360,11 @@ export namespace Wamp {
332
360
 
333
361
  /**
334
362
  * Marks a method as the WAMP "close" event handler.
335
- * @author Axel Nana <axel.nana@workbud.com>
336
363
  *
337
364
  * This decorator should be used on a WAMP controller method. Only one "close" event handler
338
365
  * can be defined per WAMP controller.
366
+ *
367
+ * @author Axel Nana <axel.nana@workbud.com>
339
368
  */
340
369
  export const onClose = (): MethodDecorator => {
341
370
  return function (target, _propertyKey, descriptor) {
@@ -347,10 +376,11 @@ export namespace Wamp {
347
376
 
348
377
  /**
349
378
  * Marks a method as the WAMP "error" event handler.
350
- * @author Axel Nana <axel.nana@workbud.com>
351
379
  *
352
380
  * This decorator should be used on a WAMP controller method. Only one "error" event handler
353
381
  * can be defined per WAMP controller.
382
+ *
383
+ * @author Axel Nana <axel.nana@workbud.com>
354
384
  */
355
385
  export const onError = (): MethodDecorator => {
356
386
  return function (target, _propertyKey, descriptor) {
@@ -362,10 +392,11 @@ export namespace Wamp {
362
392
 
363
393
  /**
364
394
  * Marks a method as the WAMP "reconnect" event handler.
365
- * @author Axel Nana <axel.nana@workbud.com>
366
395
  *
367
396
  * This decorator should be used on a WAMP controller method. Only one "reconnect" event handler
368
397
  * can be defined per WAMP controller.
398
+ *
399
+ * @author Axel Nana <axel.nana@workbud.com>
369
400
  */
370
401
  export const onReconnect = (): MethodDecorator => {
371
402
  return function (target, _propertyKey, descriptor) {
@@ -377,10 +408,11 @@ export namespace Wamp {
377
408
 
378
409
  /**
379
410
  * Marks a method as the WAMP "reconnectSuccess" event handler.
380
- * @author Axel Nana <axel.nana@workbud.com>
381
411
  *
382
412
  * This decorator should be used on a WAMP controller method. Only one "reconnectSuccess" event handler
383
413
  * can be defined per WAMP controller.
414
+ *
415
+ * @author Axel Nana <axel.nana@workbud.com>
384
416
  */
385
417
  export const onReconnectSuccess = (): MethodDecorator => {
386
418
  return function (target, _propertyKey, descriptor) {
package/tsconfig.json ADDED
@@ -0,0 +1,47 @@
1
+ // Copyright (c) 2025-present Workbud Technologies Inc. All rights reserved.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ {
15
+ "compilerOptions": {
16
+ // Environment setup & latest features
17
+ "target": "ESNext",
18
+ "module": "ESNext",
19
+ "moduleDetection": "force",
20
+ "allowJs": true,
21
+ "types": [
22
+ "bun-types",
23
+ "type-fest"
24
+ ],
25
+ "experimentalDecorators": true,
26
+ "emitDecoratorMetadata": true,
27
+ // Bundler mode
28
+ "moduleResolution": "bundler",
29
+ "allowImportingTsExtensions": true,
30
+ "verbatimModuleSyntax": true,
31
+ "noEmit": true,
32
+ // Best practices
33
+ "strict": true,
34
+ "skipLibCheck": true,
35
+ "noFallthroughCasesInSwitch": true,
36
+ "noUncheckedIndexedAccess": false,
37
+ "forceConsistentCasingInFileNames": true,
38
+ // Some stricter flags (disabled by default)
39
+ "noUnusedLocals": true,
40
+ "noUnusedParameters": true,
41
+ "noPropertyAccessFromIndexSignature": false,
42
+ },
43
+ "exclude": [
44
+ "node_modules",
45
+ "dist",
46
+ ],
47
+ }