@onebun/core 0.2.12 → 0.2.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -3964,13 +3964,6 @@ describe('OneBunApplication', () => {
3964
3964
  };
3965
3965
  }
3966
3966
 
3967
- async addScheduledJob(): Promise<void> {}
3968
- async removeScheduledJob(): Promise<boolean> {
3969
- return false;
3970
- }
3971
- async getScheduledJobs(): Promise<import('../queue/types').ScheduledJobInfo[]> {
3972
- return [];
3973
- }
3974
3967
  supports(): boolean {
3975
3968
  return false;
3976
3969
  }
@@ -14,6 +14,7 @@ import {
14
14
 
15
15
  import {
16
16
  defineMetadata,
17
+ diagnoseDecoratorMetadata,
17
18
  getMetadata,
18
19
  getConstructorParamTypes,
19
20
  setConstructorParamTypes,
@@ -731,6 +732,91 @@ describe('Metadata System', () => {
731
732
  });
732
733
  });
733
734
 
735
+ describe('diagnoseDecoratorMetadata', () => {
736
+ test('should return ok when no classes have constructor params', () => {
737
+ class NoParams {}
738
+
739
+ const result = diagnoseDecoratorMetadata([NoParams]);
740
+
741
+ expect(result.ok).toBe(true);
742
+ expect(result.classesWithParams).toBe(0);
743
+ expect(result.classesWithMetadata).toBe(0);
744
+ });
745
+
746
+ test('should return ok when classes with params have metadata', () => {
747
+ class DepA {}
748
+ class WithMeta {
749
+ constructor(_dep: DepA) {}
750
+ }
751
+
752
+ // Simulate Bun emitting design:paramtypes via Reflect polyfill
753
+ (globalThis as any).Reflect.defineMetadata('design:paramtypes', [DepA], WithMeta);
754
+
755
+ const result = diagnoseDecoratorMetadata([WithMeta]);
756
+
757
+ expect(result.ok).toBe(true);
758
+ expect(result.classesWithParams).toBe(1);
759
+ expect(result.classesWithMetadata).toBe(1);
760
+ });
761
+
762
+ test('should return not ok when classes with params have NO metadata', () => {
763
+ // Use a fresh class without any metadata set
764
+ class NoDep {}
765
+ class BrokenService {
766
+ constructor(_dep: NoDep) {}
767
+ }
768
+
769
+ const result = diagnoseDecoratorMetadata([BrokenService]);
770
+
771
+ expect(result.ok).toBe(false);
772
+ expect(result.classesWithParams).toBe(1);
773
+ expect(result.classesWithMetadata).toBe(0);
774
+ });
775
+
776
+ test('should return ok when at least one class has metadata', () => {
777
+ class DepA {}
778
+ class DepB {}
779
+
780
+ class ServiceA {
781
+ constructor(_dep: DepA) {}
782
+ }
783
+ class ServiceB {
784
+ constructor(_dep: DepB) {}
785
+ }
786
+
787
+ // Only one has metadata — still ok (metadata emission works in principle)
788
+ (globalThis as any).Reflect.defineMetadata('design:paramtypes', [DepA], ServiceA);
789
+
790
+ const result = diagnoseDecoratorMetadata([ServiceA, ServiceB]);
791
+
792
+ expect(result.ok).toBe(true);
793
+ expect(result.classesWithParams).toBe(2);
794
+ expect(result.classesWithMetadata).toBe(1);
795
+ });
796
+
797
+ test('should return ok for empty class list', () => {
798
+ const result = diagnoseDecoratorMetadata([]);
799
+
800
+ expect(result.ok).toBe(true);
801
+ expect(result.classesWithParams).toBe(0);
802
+ });
803
+
804
+ test('should handle mixed classes with and without params', () => {
805
+ class Dep {}
806
+ class NoParamsService {}
807
+ class WithParamsService {
808
+ constructor(_dep: Dep) {}
809
+ }
810
+
811
+ // No metadata for WithParamsService
812
+ const result = diagnoseDecoratorMetadata([NoParamsService, WithParamsService]);
813
+
814
+ expect(result.ok).toBe(false);
815
+ expect(result.classesWithParams).toBe(1);
816
+ expect(result.classesWithMetadata).toBe(0);
817
+ });
818
+ });
819
+
734
820
  describe('Metadata storage edge cases', () => {
735
821
  test('should handle metadata on object values', () => {
736
822
  const objectValue = { value: 42 };
@@ -286,6 +286,205 @@ export function getConstructorParamTypes(target: Function): Function[] | undefin
286
286
  return undefined;
287
287
  }
288
288
 
289
+ /**
290
+ * Diagnostic: check whether Bun is emitting decorator metadata (design:paramtypes).
291
+ *
292
+ * Scans an array of decorated classes. If at least one class has constructor
293
+ * parameters (target.length > 0) but NONE of them have design:paramtypes,
294
+ * then emitDecoratorMetadata is not working — typically because the setting
295
+ * is missing from the root tsconfig.json that Bun actually reads.
296
+ *
297
+ * @param decoratedClasses - Classes registered via @Service / @Controller / @Middleware
298
+ * @returns Object with diagnostic result and details
299
+ */
300
+ export function diagnoseDecoratorMetadata(decoratedClasses: Function[]): {
301
+ ok: boolean;
302
+ classesWithParams: number;
303
+ classesWithMetadata: number;
304
+ } {
305
+ let classesWithParams = 0;
306
+ let classesWithMetadata = 0;
307
+
308
+ for (const cls of decoratedClasses) {
309
+ if (cls.length > 0) {
310
+ classesWithParams++;
311
+ const types = (globalThis as any).Reflect?.getMetadata?.('design:paramtypes', cls);
312
+ if (types && Array.isArray(types) && types.length > 0) {
313
+ classesWithMetadata++;
314
+ }
315
+ }
316
+ }
317
+
318
+ return {
319
+ ok: classesWithParams === 0 || classesWithMetadata > 0,
320
+ classesWithParams,
321
+ classesWithMetadata,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Build a detailed diagnostic message by inspecting the project's tsconfig files.
327
+ *
328
+ * Walks up from `process.cwd()` looking for tsconfig.json files, reads them,
329
+ * and tells the user exactly which file to edit and what to add.
330
+ */
331
+ export function buildDecoratorMetadataDiagnosticMessage(
332
+ classesWithParams: number,
333
+ ): string {
334
+ const fs = require('node:fs');
335
+ const pathModule = require('node:path');
336
+
337
+ const header =
338
+ `[OneBun] Dependency injection is broken: none of the ${classesWithParams} ` +
339
+ 'service(s) with constructor parameters have design:paramtypes metadata.\n' +
340
+ 'Bun is NOT emitting decorator metadata.\n';
341
+
342
+ type TsconfigInfo = { path: string; content: any; hasEmit: boolean; hasExperimental: boolean };
343
+
344
+ const readTsconfig = (tsconfigPath: string): TsconfigInfo | null => {
345
+ try {
346
+ if (!fs.existsSync(tsconfigPath)) {
347
+ return null;
348
+ }
349
+ const raw = fs.readFileSync(tsconfigPath, 'utf-8');
350
+ // Strip comments (single-line // and multi-line /* */) for JSON.parse
351
+ const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
352
+ const parsed = JSON.parse(stripped);
353
+ const compilerOptions = parsed.compilerOptions || {};
354
+
355
+ return {
356
+ path: tsconfigPath,
357
+ content: parsed,
358
+ hasEmit: compilerOptions.emitDecoratorMetadata === true,
359
+ hasExperimental: compilerOptions.experimentalDecorators === true,
360
+ };
361
+ } catch {
362
+ return null;
363
+ }
364
+ };
365
+
366
+ // 1. Find tsconfig.json files walking UP from cwd to filesystem root
367
+ const tsconfigFiles: TsconfigInfo[] = [];
368
+ let dir = process.cwd();
369
+ const root = pathModule.parse(dir).root;
370
+
371
+ while (dir !== root) {
372
+ const info = readTsconfig(pathModule.join(dir, 'tsconfig.json'));
373
+ if (info) {
374
+ tsconfigFiles.push(info);
375
+ }
376
+ dir = pathModule.dirname(dir);
377
+ }
378
+
379
+ // 2. Also scan immediate subdirectories of cwd for child tsconfig.json files
380
+ // (e.g. packages/backend/tsconfig.json in a monorepo)
381
+ const childTsconfigFiles: TsconfigInfo[] = [];
382
+ try {
383
+ const scanDirs = [process.cwd()];
384
+ // Scan up to 2 levels deep to find packages/*/tsconfig.json and similar
385
+ for (const scanDir of scanDirs) {
386
+ const entries = fs.readdirSync(scanDir, { withFileTypes: true });
387
+ for (const entry of entries) {
388
+ if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
389
+ const subdir = pathModule.join(scanDir, entry.name);
390
+ const info = readTsconfig(pathModule.join(subdir, 'tsconfig.json'));
391
+ if (info) {
392
+ childTsconfigFiles.push(info);
393
+ }
394
+ // One level deeper (e.g. packages/backend/)
395
+ try {
396
+ const subEntries = fs.readdirSync(subdir, { withFileTypes: true });
397
+ for (const subEntry of subEntries) {
398
+ if (subEntry.isDirectory() && !subEntry.name.startsWith('.') && subEntry.name !== 'node_modules') {
399
+ const info2 = readTsconfig(pathModule.join(subdir, subEntry.name, 'tsconfig.json'));
400
+ if (info2) {
401
+ childTsconfigFiles.push(info2);
402
+ }
403
+ }
404
+ }
405
+ } catch {
406
+ // Skip unreadable directories
407
+ }
408
+ }
409
+ }
410
+ }
411
+ } catch {
412
+ // Skip if directory scanning fails
413
+ }
414
+
415
+ if (tsconfigFiles.length === 0) {
416
+ return (
417
+ header +
418
+ '\nNo tsconfig.json found. Create one in your project root with:\n\n' +
419
+ ' {\n' +
420
+ ' "compilerOptions": {\n' +
421
+ ' "experimentalDecorators": true,\n' +
422
+ ' "emitDecoratorMetadata": true\n' +
423
+ ' }\n' +
424
+ ' }\n'
425
+ );
426
+ }
427
+
428
+ // The first (deepest / closest to cwd) tsconfig is what the user likely expects to work.
429
+ // The last (shallowest / closest to root) is what Bun actually reads.
430
+ const rootTsconfig = tsconfigFiles[tsconfigFiles.length - 1];
431
+
432
+ // Check if any child tsconfig has the settings but root does not
433
+ // Look in both parent chain and scanned subdirectories
434
+ const allConfigs = [...tsconfigFiles, ...childTsconfigFiles];
435
+ const childWithSettings = allConfigs.find(
436
+ (t) => t.path !== rootTsconfig.path && (t.hasEmit || t.hasExperimental),
437
+ );
438
+
439
+ const lines: string[] = [header];
440
+
441
+ if (rootTsconfig.hasEmit && rootTsconfig.hasExperimental) {
442
+ // Root has both settings — unusual, might be a different issue
443
+ lines.push(`\nRoot tsconfig (${rootTsconfig.path}) already has both settings.`);
444
+ lines.push('If DI is still broken, check that Bun is reading this file for your entry point.');
445
+
446
+ return lines.join('\n');
447
+ }
448
+
449
+ // Report what's missing from the root tsconfig
450
+ const missing: string[] = [];
451
+ if (!rootTsconfig.hasExperimental) {
452
+ missing.push('"experimentalDecorators": true');
453
+ }
454
+ if (!rootTsconfig.hasEmit) {
455
+ missing.push('"emitDecoratorMetadata": true');
456
+ }
457
+
458
+ lines.push(`\nRoot tsconfig: ${rootTsconfig.path}`);
459
+ lines.push(`Missing in "compilerOptions": ${missing.join(', ')}`);
460
+
461
+ if (childWithSettings) {
462
+ const childHas: string[] = [];
463
+ if (childWithSettings.hasExperimental) {
464
+ childHas.push('"experimentalDecorators": true');
465
+ }
466
+ if (childWithSettings.hasEmit) {
467
+ childHas.push('"emitDecoratorMetadata": true');
468
+ }
469
+ lines.push(
470
+ `\nNote: ${childWithSettings.path} has ${childHas.join(' and ')},` +
471
+ ' but Bun ignores these when they are only in a child tsconfig.',
472
+ );
473
+ }
474
+
475
+ lines.push(`\nFix: add the missing option(s) to ${rootTsconfig.path}:\n`);
476
+ lines.push(' "compilerOptions": {');
477
+ if (!rootTsconfig.hasExperimental) {
478
+ lines.push(' "experimentalDecorators": true,');
479
+ }
480
+ if (!rootTsconfig.hasEmit) {
481
+ lines.push(' "emitDecoratorMetadata": true');
482
+ }
483
+ lines.push(' }');
484
+
485
+ return lines.join('\n');
486
+ }
487
+
289
488
  /**
290
489
  * Namespace for metadata functions to mimic Reflect API
291
490
  */
@@ -22,6 +22,7 @@ import {
22
22
  isGlobalModule,
23
23
  registerControllerDependencies,
24
24
  } from '../decorators/decorators';
25
+ import { buildDecoratorMetadataDiagnosticMessage, diagnoseDecoratorMetadata } from '../decorators/metadata';
25
26
  import { QueueService, QueueServiceTag } from '../queue';
26
27
  import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
27
28
  import { isWebSocketGateway } from '../websocket/ws-decorators';
@@ -70,6 +71,7 @@ const processedGlobalModules: Set<Function> = ((globalThis as any)[Symbol.for('o
70
71
  export function clearGlobalServicesRegistry(): void {
71
72
  globalServicesRegistry.clear();
72
73
  processedGlobalModules.clear();
74
+ OneBunModule.resetDecoratorMetadataDiagnosis();
73
75
  }
74
76
 
75
77
  /**
@@ -274,6 +276,20 @@ export class OneBunModule implements ModuleInstance {
274
276
  * Create services with automatic dependency injection
275
277
  * Services can depend on other services (including imported ones)
276
278
  */
279
+ /**
280
+ * Whether the decorator metadata diagnostic has already run (once per process).
281
+ * Prevents repeated checks across multiple module initializations.
282
+ */
283
+ private static decoratorMetadataDiagnosed = false;
284
+
285
+ /**
286
+ * Reset the diagnostic flag (for testing).
287
+ * @internal
288
+ */
289
+ static resetDecoratorMetadataDiagnosis(): void {
290
+ OneBunModule.decoratorMetadataDiagnosed = false;
291
+ }
292
+
277
293
  private createServicesWithDI(metadata: ReturnType<typeof getModuleMetadata>): void {
278
294
  if (!metadata?.providers) {
279
295
  return;
@@ -295,6 +311,26 @@ export class OneBunModule implements ModuleInstance {
295
311
  }
296
312
  }
297
313
 
314
+ // Run decorator metadata diagnostic once: check that Bun emits design:paramtypes.
315
+ // If emitDecoratorMetadata is missing from the root tsconfig.json, Bun silently
316
+ // skips metadata emission and ALL constructor-based DI breaks.
317
+ // Only mark as diagnosed when we actually found classes with constructor params
318
+ // (modules with only zero-arg services can't tell us anything).
319
+ if (!OneBunModule.decoratorMetadataDiagnosed) {
320
+ const providerClasses = metadata.providers.filter(
321
+ (p): p is Function => typeof p === 'function',
322
+ );
323
+ const diagnosis = diagnoseDecoratorMetadata(providerClasses);
324
+ if (diagnosis.classesWithParams > 0) {
325
+ OneBunModule.decoratorMetadataDiagnosed = true;
326
+ if (!diagnosis.ok) {
327
+ throw new Error(
328
+ buildDecoratorMetadataDiagnosticMessage(diagnosis.classesWithParams),
329
+ );
330
+ }
331
+ }
332
+ }
333
+
298
334
  // Create services in dependency order
299
335
  const pendingProviders = [...metadata.providers.filter((p) => typeof p === 'function')];
300
336
  const createdServices = new Set<string>();
@@ -384,10 +384,6 @@ describe('InMemoryQueueAdapter', () => {
384
384
  expect(adapter.supports('priority')).toBe(true);
385
385
  });
386
386
 
387
- it('should support scheduled-jobs', () => {
388
- expect(adapter.supports('scheduled-jobs')).toBe(true);
389
- });
390
-
391
387
  it('should not support consumer-groups', () => {
392
388
  expect(adapter.supports('consumer-groups')).toBe(false);
393
389
  });
@@ -15,8 +15,6 @@ import type {
15
15
  PublishOptions,
16
16
  SubscribeOptions,
17
17
  Subscription,
18
- ScheduledJobOptions,
19
- ScheduledJobInfo,
20
18
  MessageHandler,
21
19
  } from '../types';
22
20
 
@@ -322,49 +320,6 @@ export class InMemoryQueueAdapter implements QueueAdapter {
322
320
  return subscription;
323
321
  }
324
322
 
325
- // ============================================================================
326
- // Scheduled Jobs
327
- // ============================================================================
328
-
329
- async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
330
- this.ensureConnected();
331
-
332
- if (!this.scheduler) {
333
- throw new Error('Scheduler not initialized');
334
- }
335
-
336
- if (options.schedule.cron) {
337
- this.scheduler.addCronJob(name, options.schedule.cron, options.pattern, () => options.data, {
338
- metadata: options.metadata,
339
- overlapStrategy: options.overlapStrategy,
340
- });
341
- } else if (options.schedule.every) {
342
- this.scheduler.addIntervalJob(name, options.schedule.every, options.pattern, () => options.data, {
343
- metadata: options.metadata,
344
- });
345
- }
346
- }
347
-
348
- async removeScheduledJob(name: string): Promise<boolean> {
349
- this.ensureConnected();
350
-
351
- if (!this.scheduler) {
352
- return false;
353
- }
354
-
355
- return this.scheduler.removeJob(name);
356
- }
357
-
358
- async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
359
- this.ensureConnected();
360
-
361
- if (!this.scheduler) {
362
- return [];
363
- }
364
-
365
- return this.scheduler.getJobs();
366
- }
367
-
368
323
  // ============================================================================
369
324
  // Features
370
325
  // ============================================================================
@@ -373,7 +328,6 @@ export class InMemoryQueueAdapter implements QueueAdapter {
373
328
  switch (feature) {
374
329
  case 'delayed-messages':
375
330
  case 'priority':
376
- case 'scheduled-jobs':
377
331
  case 'pattern-subscriptions':
378
332
  return true;
379
333
  case 'consumer-groups':
@@ -158,76 +158,12 @@ describe('RedisQueueAdapter', () => {
158
158
  // current RedisClient.raw() implementation. These features need a proper
159
159
  // implementation using Bun's Redis client's sendCommand API.
160
160
 
161
- describe('scheduled jobs', () => {
162
- beforeEach(async () => {
163
- await adapter.connect();
164
- });
165
-
166
- it('should add and get scheduled jobs', async () => {
167
- await adapter.addScheduledJob('test-job', {
168
- pattern: 'job:test',
169
- data: { action: 'process' },
170
- schedule: { every: 1000 },
171
- });
172
-
173
- const jobs = await adapter.getScheduledJobs();
174
-
175
- expect(jobs.find((j) => j.name === 'test-job')).toBeDefined();
176
- });
177
-
178
- it('should add cron scheduled job', async () => {
179
- await adapter.addScheduledJob('cron-job', {
180
- pattern: 'job:cron',
181
- data: { action: 'cron' },
182
- schedule: { cron: '0 * * * *' },
183
- });
184
-
185
- const jobs = await adapter.getScheduledJobs();
186
-
187
- expect(jobs.find((j) => j.name === 'cron-job')).toBeDefined();
188
- });
189
-
190
- it('should remove scheduled job', async () => {
191
- await adapter.addScheduledJob('removable-job', {
192
- pattern: 'job:remove',
193
- data: {},
194
- schedule: { every: 1000 },
195
- });
196
-
197
- const removed = await adapter.removeScheduledJob('removable-job');
198
-
199
- expect(removed).toBe(true);
200
-
201
- const jobs = await adapter.getScheduledJobs();
202
- expect(jobs.find((j) => j.name === 'removable-job')).toBeUndefined();
203
- });
204
-
205
- it('should return false when removing non-existent job', async () => {
206
- const removed = await adapter.removeScheduledJob('non-existent');
207
-
208
- expect(removed).toBe(false);
209
- });
210
-
211
- it('should throw when adding job while disconnected', async () => {
212
- await adapter.disconnect();
213
-
214
- await expect(
215
- adapter.addScheduledJob('fail-job', {
216
- pattern: 'job:fail',
217
- data: {},
218
- schedule: { every: 1000 },
219
- }),
220
- ).rejects.toThrow('not connected');
221
- });
222
- });
223
-
224
161
  describe('features', () => {
225
162
  it('should support all standard queue features', () => {
226
163
  expect(adapter.supports('delayed-messages')).toBe(true);
227
164
  expect(adapter.supports('priority')).toBe(true);
228
165
  expect(adapter.supports('dead-letter-queue')).toBe(true);
229
166
  expect(adapter.supports('retry')).toBe(true);
230
- expect(adapter.supports('scheduled-jobs')).toBe(true);
231
167
  expect(adapter.supports('consumer-groups')).toBe(true);
232
168
  expect(adapter.supports('pattern-subscriptions')).toBe(true);
233
169
  });
@@ -21,8 +21,6 @@ import type {
21
21
  PublishOptions,
22
22
  SubscribeOptions,
23
23
  Subscription,
24
- ScheduledJobOptions,
25
- ScheduledJobInfo,
26
24
  MessageHandler,
27
25
  } from '../types';
28
26
 
@@ -409,45 +407,6 @@ export class RedisQueueAdapter implements QueueAdapter {
409
407
  return subscription;
410
408
  }
411
409
 
412
- // ============================================================================
413
- // Scheduled Jobs
414
- // ============================================================================
415
-
416
- async addScheduledJob(name: string, options: ScheduledJobOptions): Promise<void> {
417
- this.ensureConnected();
418
-
419
- if (!this.scheduler) {
420
- throw new Error('Scheduler not initialized');
421
- }
422
-
423
- if (options.schedule.cron) {
424
- this.scheduler.addCronJob(name, options.schedule.cron, options.pattern, () => options.data, {
425
- metadata: options.metadata,
426
- overlapStrategy: options.overlapStrategy,
427
- });
428
- } else if (options.schedule.every) {
429
- this.scheduler.addIntervalJob(name, options.schedule.every, options.pattern, () => options.data, {
430
- metadata: options.metadata,
431
- });
432
- }
433
- }
434
-
435
- async removeScheduledJob(name: string): Promise<boolean> {
436
- if (!this.scheduler) {
437
- return false;
438
- }
439
-
440
- return this.scheduler.removeJob(name);
441
- }
442
-
443
- async getScheduledJobs(): Promise<ScheduledJobInfo[]> {
444
- if (!this.scheduler) {
445
- return [];
446
- }
447
-
448
- return this.scheduler.getJobs();
449
- }
450
-
451
410
  // ============================================================================
452
411
  // Features
453
412
  // ============================================================================