create-forgeon 0.3.5 → 0.3.7

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.
Files changed (109) hide show
  1. package/package.json +1 -1
  2. package/src/cli/add-help.mjs +1 -0
  3. package/src/cli/add-options.mjs +6 -0
  4. package/src/cli/add-options.test.mjs +2 -0
  5. package/src/modules/dependencies.mjs +31 -0
  6. package/src/modules/dependencies.test.mjs +207 -5
  7. package/src/modules/executor.mjs +14 -0
  8. package/src/modules/executor.test.mjs +752 -14
  9. package/src/modules/files-access.mjs +446 -0
  10. package/src/modules/files-image.mjs +540 -0
  11. package/src/modules/files-local.mjs +221 -0
  12. package/src/modules/files-quotas.mjs +402 -0
  13. package/src/modules/files-s3.mjs +266 -0
  14. package/src/modules/files.mjs +527 -0
  15. package/src/modules/queue.mjs +410 -0
  16. package/src/modules/registry.mjs +93 -3
  17. package/src/modules/shared/patch-utils.mjs +25 -0
  18. package/src/run-add-module.mjs +89 -2
  19. package/templates/module-fragments/files/00_title.md +1 -0
  20. package/templates/module-fragments/files/10_overview.md +17 -0
  21. package/templates/module-fragments/files/20_scope.md +13 -0
  22. package/templates/module-fragments/files/90_status_implemented.md +3 -0
  23. package/templates/module-fragments/files-access/00_title.md +1 -0
  24. package/templates/module-fragments/files-access/10_overview.md +9 -0
  25. package/templates/module-fragments/files-access/20_scope.md +20 -0
  26. package/templates/module-fragments/files-access/90_status_implemented.md +3 -0
  27. package/templates/module-fragments/files-image/00_title.md +1 -0
  28. package/templates/module-fragments/files-image/10_overview.md +10 -0
  29. package/templates/module-fragments/files-image/20_scope.md +20 -0
  30. package/templates/module-fragments/files-image/90_status_implemented.md +3 -0
  31. package/templates/module-fragments/files-local/00_title.md +1 -0
  32. package/templates/module-fragments/files-local/10_overview.md +9 -0
  33. package/templates/module-fragments/files-local/20_scope.md +10 -0
  34. package/templates/module-fragments/files-local/90_status_implemented.md +3 -0
  35. package/templates/module-fragments/files-quotas/00_title.md +1 -0
  36. package/templates/module-fragments/files-quotas/10_overview.md +9 -0
  37. package/templates/module-fragments/files-quotas/20_scope.md +20 -0
  38. package/templates/module-fragments/files-quotas/90_status_implemented.md +3 -0
  39. package/templates/module-fragments/files-s3/00_title.md +1 -0
  40. package/templates/module-fragments/files-s3/10_overview.md +17 -0
  41. package/templates/module-fragments/files-s3/20_scope.md +11 -0
  42. package/templates/module-fragments/files-s3/90_status_implemented.md +5 -0
  43. package/templates/module-fragments/queue/20_scope.md +8 -7
  44. package/templates/module-fragments/queue/90_status_implemented.md +3 -0
  45. package/templates/module-presets/files/apps/api/prisma/migrations/20260306_files_file_record/migration.sql +30 -0
  46. package/templates/module-presets/files/apps/api/prisma/migrations/20260306_files_file_variant/migration.sql +55 -0
  47. package/templates/module-presets/files/packages/files/package.json +24 -0
  48. package/templates/module-presets/files/packages/files/src/dto/create-file.dto.ts +30 -0
  49. package/templates/module-presets/files/packages/files/src/files-config.loader.ts +21 -0
  50. package/templates/module-presets/files/packages/files/src/files-config.module.ts +12 -0
  51. package/templates/module-presets/files/packages/files/src/files-config.service.ts +32 -0
  52. package/templates/module-presets/files/packages/files/src/files-env.schema.ts +30 -0
  53. package/templates/module-presets/files/packages/files/src/files.controller.ts +90 -0
  54. package/templates/module-presets/files/packages/files/src/files.service.ts +762 -0
  55. package/templates/module-presets/files/packages/files/src/files.types.ts +35 -0
  56. package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +12 -0
  57. package/templates/module-presets/files/packages/files/src/index.ts +9 -0
  58. package/templates/module-presets/files/packages/files/tsconfig.json +9 -0
  59. package/templates/module-presets/files-access/packages/files-access/package.json +17 -0
  60. package/templates/module-presets/files-access/packages/files-access/src/files-access.service.ts +59 -0
  61. package/templates/module-presets/files-access/packages/files-access/src/files-access.subject.ts +45 -0
  62. package/templates/module-presets/files-access/packages/files-access/src/files-access.types.ts +14 -0
  63. package/templates/module-presets/files-access/packages/files-access/src/forgeon-files-access.module.ts +8 -0
  64. package/templates/module-presets/files-access/packages/files-access/src/index.ts +4 -0
  65. package/templates/module-presets/files-access/packages/files-access/tsconfig.json +9 -0
  66. package/templates/module-presets/files-image/packages/files-image/package.json +21 -0
  67. package/templates/module-presets/files-image/packages/files-image/src/files-image-config.loader.ts +32 -0
  68. package/templates/module-presets/files-image/packages/files-image/src/files-image-config.module.ts +11 -0
  69. package/templates/module-presets/files-image/packages/files-image/src/files-image-config.service.ts +55 -0
  70. package/templates/module-presets/files-image/packages/files-image/src/files-image-env.schema.ts +28 -0
  71. package/templates/module-presets/files-image/packages/files-image/src/files-image.service.ts +420 -0
  72. package/templates/module-presets/files-image/packages/files-image/src/files-image.types.ts +18 -0
  73. package/templates/module-presets/files-image/packages/files-image/src/forgeon-files-image.module.ts +10 -0
  74. package/templates/module-presets/files-image/packages/files-image/src/index.ts +7 -0
  75. package/templates/module-presets/files-image/packages/files-image/tsconfig.json +9 -0
  76. package/templates/module-presets/files-local/packages/files-local/package.json +19 -0
  77. package/templates/module-presets/files-local/packages/files-local/src/files-local-config.loader.ts +13 -0
  78. package/templates/module-presets/files-local/packages/files-local/src/files-local-config.module.ts +12 -0
  79. package/templates/module-presets/files-local/packages/files-local/src/files-local-config.service.ts +11 -0
  80. package/templates/module-presets/files-local/packages/files-local/src/files-local-env.schema.ts +13 -0
  81. package/templates/module-presets/files-local/packages/files-local/src/index.ts +4 -0
  82. package/templates/module-presets/files-local/packages/files-local/tsconfig.json +9 -0
  83. package/templates/module-presets/files-quotas/packages/files-quotas/package.json +20 -0
  84. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.loader.ts +22 -0
  85. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.module.ts +11 -0
  86. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.service.ts +27 -0
  87. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-env.schema.ts +15 -0
  88. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -0
  89. package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.types.ts +22 -0
  90. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +11 -0
  91. package/templates/module-presets/files-quotas/packages/files-quotas/src/index.ts +7 -0
  92. package/templates/module-presets/files-quotas/packages/files-quotas/tsconfig.json +9 -0
  93. package/templates/module-presets/files-s3/packages/files-s3/package.json +20 -0
  94. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.loader.ts +57 -0
  95. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.module.ts +12 -0
  96. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.service.ts +44 -0
  97. package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-env.schema.ts +51 -0
  98. package/templates/module-presets/files-s3/packages/files-s3/src/index.ts +4 -0
  99. package/templates/module-presets/files-s3/packages/files-s3/tsconfig.json +9 -0
  100. package/templates/module-presets/queue/packages/queue/package.json +21 -0
  101. package/templates/module-presets/queue/packages/queue/src/forgeon-queue.module.ts +10 -0
  102. package/templates/module-presets/queue/packages/queue/src/index.ts +6 -0
  103. package/templates/module-presets/queue/packages/queue/src/queue-config.loader.ts +24 -0
  104. package/templates/module-presets/queue/packages/queue/src/queue-config.module.ts +10 -0
  105. package/templates/module-presets/queue/packages/queue/src/queue-config.service.ts +69 -0
  106. package/templates/module-presets/queue/packages/queue/src/queue-env.schema.ts +17 -0
  107. package/templates/module-presets/queue/packages/queue/src/queue.service.ts +88 -0
  108. package/templates/module-presets/queue/packages/queue/tsconfig.json +9 -0
  109. package/templates/module-fragments/queue/90_status_planned.md +0 -3
@@ -0,0 +1,118 @@
1
+ import { ConflictException, Injectable } from '@nestjs/common';
2
+ import { FilesService } from '@forgeon/files';
3
+ import { FilesQuotasConfigService } from './files-quotas-config.service';
4
+ import type { FilesQuotaCheckInput, FilesQuotaCheckResult } from './files-quotas.types';
5
+
6
+ @Injectable()
7
+ export class FilesQuotasService {
8
+ constructor(
9
+ private readonly filesService: FilesService,
10
+ private readonly configService: FilesQuotasConfigService,
11
+ ) {}
12
+
13
+ get enabled(): boolean {
14
+ return this.configService.enabled;
15
+ }
16
+
17
+ get limits() {
18
+ return {
19
+ maxFilesPerOwner: this.configService.maxFilesPerOwner,
20
+ maxBytesPerOwner: this.configService.maxBytesPerOwner,
21
+ };
22
+ }
23
+
24
+ async evaluateUpload(input: FilesQuotaCheckInput): Promise<FilesQuotaCheckResult> {
25
+ const limits = this.limits;
26
+ const emptyUsage = {
27
+ filesCount: 0,
28
+ totalBytes: 0,
29
+ };
30
+ const baseResult = {
31
+ limits,
32
+ current: emptyUsage,
33
+ next: {
34
+ filesCount: emptyUsage.filesCount + 1,
35
+ totalBytes: emptyUsage.totalBytes + input.fileSize,
36
+ },
37
+ };
38
+
39
+ if (!this.enabled) {
40
+ return {
41
+ allowed: true,
42
+ reason: 'disabled',
43
+ ...baseResult,
44
+ };
45
+ }
46
+
47
+ if (!input.ownerId) {
48
+ return {
49
+ allowed: true,
50
+ reason: 'owner-missing',
51
+ ...baseResult,
52
+ };
53
+ }
54
+
55
+ const usage = await this.filesService.getOwnerUsage(input.ownerType, input.ownerId);
56
+ const next = {
57
+ filesCount: usage.filesCount + 1,
58
+ totalBytes: usage.totalBytes + input.fileSize,
59
+ };
60
+
61
+ if (next.filesCount > limits.maxFilesPerOwner) {
62
+ return {
63
+ allowed: false,
64
+ reason: 'max-files',
65
+ limits,
66
+ current: usage,
67
+ next,
68
+ };
69
+ }
70
+ if (next.totalBytes > limits.maxBytesPerOwner) {
71
+ return {
72
+ allowed: false,
73
+ reason: 'max-bytes',
74
+ limits,
75
+ current: usage,
76
+ next,
77
+ };
78
+ }
79
+
80
+ return {
81
+ allowed: true,
82
+ reason: 'ok',
83
+ limits,
84
+ current: usage,
85
+ next,
86
+ };
87
+ }
88
+
89
+ async assertUploadAllowed(input: FilesQuotaCheckInput): Promise<void> {
90
+ const result = await this.evaluateUpload(input);
91
+ if (result.allowed) {
92
+ return;
93
+ }
94
+
95
+ throw new ConflictException({
96
+ message: 'File quota exceeded for owner',
97
+ details: {
98
+ reason: result.reason,
99
+ limits: result.limits,
100
+ current: result.current,
101
+ next: result.next,
102
+ },
103
+ });
104
+ }
105
+
106
+ async getProbeStatus(input: FilesQuotaCheckInput): Promise<{
107
+ status: 'ok';
108
+ feature: 'files-quotas';
109
+ result: FilesQuotaCheckResult;
110
+ }> {
111
+ const result = await this.evaluateUpload(input);
112
+ return {
113
+ status: 'ok',
114
+ feature: 'files-quotas',
115
+ result,
116
+ };
117
+ }
118
+ }
@@ -0,0 +1,22 @@
1
+ export type FilesQuotaCheckInput = {
2
+ ownerType: string;
3
+ ownerId: string | null;
4
+ fileSize: number;
5
+ };
6
+
7
+ export type FilesQuotaCheckResult = {
8
+ allowed: boolean;
9
+ reason: 'ok' | 'disabled' | 'owner-missing' | 'max-files' | 'max-bytes';
10
+ limits: {
11
+ maxFilesPerOwner: number;
12
+ maxBytesPerOwner: number;
13
+ };
14
+ current: {
15
+ filesCount: number;
16
+ totalBytes: number;
17
+ };
18
+ next: {
19
+ filesCount: number;
20
+ totalBytes: number;
21
+ };
22
+ };
@@ -0,0 +1,11 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ForgeonFilesModule } from '@forgeon/files';
3
+ import { FilesQuotasConfigModule } from './files-quotas-config.module';
4
+ import { FilesQuotasService } from './files-quotas.service';
5
+
6
+ @Module({
7
+ imports: [ForgeonFilesModule, FilesQuotasConfigModule],
8
+ providers: [FilesQuotasService],
9
+ exports: [FilesQuotasConfigModule, FilesQuotasService],
10
+ })
11
+ export class ForgeonFilesQuotasModule {}
@@ -0,0 +1,7 @@
1
+ export * from './files-quotas-config.loader';
2
+ export * from './files-quotas-config.module';
3
+ export * from './files-quotas-config.service';
4
+ export * from './files-quotas-env.schema';
5
+ export * from './files-quotas.service';
6
+ export * from './files-quotas.types';
7
+ export * from './forgeon-files-quotas.module';
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.node.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@forgeon/files-s3",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json"
9
+ },
10
+ "dependencies": {
11
+ "@aws-sdk/client-s3": "^3.922.0",
12
+ "@nestjs/common": "^11.0.1",
13
+ "@nestjs/config": "^4.0.0",
14
+ "zod": "^3.24.2"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.10.7",
18
+ "typescript": "^5.7.3"
19
+ }
20
+ }
@@ -0,0 +1,57 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { parseFilesS3Env } from './files-s3-env.schema';
3
+
4
+ export type FilesS3ProviderPreset = 'minio' | 'r2' | 'aws' | 'custom';
5
+
6
+ export type FilesS3ConfigValue = {
7
+ providerPreset: FilesS3ProviderPreset;
8
+ bucket: string;
9
+ region: string;
10
+ endpoint?: string;
11
+ accessKeyId: string;
12
+ secretAccessKey: string;
13
+ forcePathStyle: boolean;
14
+ maxAttempts: number;
15
+ };
16
+
17
+ const providerDefaults: Record<
18
+ FilesS3ProviderPreset,
19
+ {
20
+ region: string;
21
+ endpoint?: string;
22
+ forcePathStyle: boolean;
23
+ }
24
+ > = {
25
+ minio: {
26
+ region: 'auto',
27
+ endpoint: 'http://localhost:9000',
28
+ forcePathStyle: true,
29
+ },
30
+ r2: {
31
+ region: 'auto',
32
+ forcePathStyle: false,
33
+ },
34
+ aws: {
35
+ region: 'eu-central-1',
36
+ forcePathStyle: false,
37
+ },
38
+ custom: {
39
+ region: 'eu-central-1',
40
+ forcePathStyle: false,
41
+ },
42
+ };
43
+
44
+ export const filesS3Config = registerAs('filesS3', (): FilesS3ConfigValue => {
45
+ const env = parseFilesS3Env(process.env);
46
+ const presetDefaults = providerDefaults[env.FILES_S3_PROVIDER_PRESET];
47
+ return {
48
+ providerPreset: env.FILES_S3_PROVIDER_PRESET,
49
+ bucket: env.FILES_S3_BUCKET,
50
+ region: env.FILES_S3_REGION ?? presetDefaults.region,
51
+ endpoint: env.FILES_S3_ENDPOINT ?? presetDefaults.endpoint,
52
+ accessKeyId: env.FILES_S3_ACCESS_KEY_ID,
53
+ secretAccessKey: env.FILES_S3_SECRET_ACCESS_KEY,
54
+ forcePathStyle: env.FILES_S3_FORCE_PATH_STYLE ?? presetDefaults.forcePathStyle,
55
+ maxAttempts: env.FILES_S3_MAX_ATTEMPTS,
56
+ };
57
+ });
@@ -0,0 +1,12 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { filesS3Config } from './files-s3-config.loader';
4
+ import { FilesS3ConfigService } from './files-s3-config.service';
5
+
6
+ @Global()
7
+ @Module({
8
+ imports: [ConfigModule.forFeature(filesS3Config)],
9
+ providers: [FilesS3ConfigService],
10
+ exports: [FilesS3ConfigService],
11
+ })
12
+ export class FilesS3ConfigModule {}
@@ -0,0 +1,44 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import type { FilesS3ConfigValue } from './files-s3-config.loader';
4
+
5
+ @Injectable()
6
+ export class FilesS3ConfigService {
7
+ constructor(private readonly configService: ConfigService) {}
8
+
9
+ get bucket(): string {
10
+ return this.configService.getOrThrow<FilesS3ConfigValue['bucket']>('filesS3.bucket');
11
+ }
12
+
13
+ get providerPreset(): FilesS3ConfigValue['providerPreset'] {
14
+ return this.configService.getOrThrow<FilesS3ConfigValue['providerPreset']>('filesS3.providerPreset');
15
+ }
16
+
17
+ get region(): string {
18
+ return this.configService.getOrThrow<FilesS3ConfigValue['region']>('filesS3.region');
19
+ }
20
+
21
+ get endpoint(): string | undefined {
22
+ return this.configService.get<FilesS3ConfigValue['endpoint']>('filesS3.endpoint');
23
+ }
24
+
25
+ get accessKeyId(): string {
26
+ return this.configService.getOrThrow<FilesS3ConfigValue['accessKeyId']>('filesS3.accessKeyId');
27
+ }
28
+
29
+ get secretAccessKey(): string {
30
+ return this.configService.getOrThrow<FilesS3ConfigValue['secretAccessKey']>(
31
+ 'filesS3.secretAccessKey',
32
+ );
33
+ }
34
+
35
+ get forcePathStyle(): boolean {
36
+ return this.configService.getOrThrow<FilesS3ConfigValue['forcePathStyle']>(
37
+ 'filesS3.forcePathStyle',
38
+ );
39
+ }
40
+
41
+ get maxAttempts(): number {
42
+ return this.configService.getOrThrow<FilesS3ConfigValue['maxAttempts']>('filesS3.maxAttempts');
43
+ }
44
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+
3
+ const s3ProviderPresetSchema = z.enum(['minio', 'r2', 'aws', 'custom']);
4
+
5
+ function normalizeOptionalString() {
6
+ return z.string().optional().transform((value) => {
7
+ if (value === undefined) {
8
+ return undefined;
9
+ }
10
+ const normalized = value.trim();
11
+ return normalized.length > 0 ? normalized : undefined;
12
+ });
13
+ }
14
+
15
+ function parseIntFromEnv(defaultValue: number, minValue: number) {
16
+ return z
17
+ .string()
18
+ .optional()
19
+ .default(String(defaultValue))
20
+ .transform((value) => Number.parseInt(value, 10))
21
+ .refine((value) => Number.isInteger(value), 'must be an integer')
22
+ .refine((value) => value >= minValue, `must be >= ${minValue}`);
23
+ }
24
+
25
+ const filesS3EnvSchema = z.object({
26
+ FILES_S3_PROVIDER_PRESET: s3ProviderPresetSchema.optional().default('minio'),
27
+ FILES_S3_BUCKET: z.string().optional().default('forgeon-files'),
28
+ FILES_S3_REGION: normalizeOptionalString(),
29
+ FILES_S3_ENDPOINT: normalizeOptionalString(),
30
+ FILES_S3_ACCESS_KEY_ID: z.string().optional().default('forgeon'),
31
+ FILES_S3_SECRET_ACCESS_KEY: z.string().optional().default('forgeon-secret'),
32
+ FILES_S3_FORCE_PATH_STYLE: z.string().optional().transform((value) => {
33
+ if (value === undefined) {
34
+ return undefined;
35
+ }
36
+ const normalized = value.trim().toLowerCase();
37
+ if (normalized.length === 0) {
38
+ return undefined;
39
+ }
40
+ return normalized !== 'false';
41
+ }),
42
+ FILES_S3_MAX_ATTEMPTS: parseIntFromEnv(3, 1),
43
+ });
44
+
45
+ export type FilesS3EnvSchema = z.infer<typeof filesS3EnvSchema>;
46
+
47
+ export function parseFilesS3Env(input: Record<string, string | undefined>): FilesS3EnvSchema {
48
+ return filesS3EnvSchema.parse(input);
49
+ }
50
+
51
+ export const filesS3EnvSchemaZod = filesS3EnvSchema;
@@ -0,0 +1,4 @@
1
+ export * from './files-s3-config.loader';
2
+ export * from './files-s3-config.module';
3
+ export * from './files-s3-config.service';
4
+ export * from './files-s3-env.schema';
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.node.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@forgeon/queue",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json"
9
+ },
10
+ "dependencies": {
11
+ "@nestjs/common": "^11.0.1",
12
+ "@nestjs/config": "^4.0.2",
13
+ "bullmq": "^5.61.0",
14
+ "rxjs": "^7.8.1",
15
+ "zod": "^3.23.8"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.10.7",
19
+ "typescript": "^5.7.3"
20
+ }
21
+ }
@@ -0,0 +1,10 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { QueueConfigModule } from './queue-config.module';
3
+ import { QueueService } from './queue.service';
4
+
5
+ @Module({
6
+ imports: [QueueConfigModule],
7
+ providers: [QueueService],
8
+ exports: [QueueConfigModule, QueueService],
9
+ })
10
+ export class ForgeonQueueModule {}
@@ -0,0 +1,6 @@
1
+ export * from './forgeon-queue.module';
2
+ export * from './queue-config.loader';
3
+ export * from './queue-config.module';
4
+ export * from './queue-config.service';
5
+ export * from './queue-env.schema';
6
+ export * from './queue.service';
@@ -0,0 +1,24 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { parseQueueEnv } from './queue-env.schema';
3
+
4
+ export const QUEUE_CONFIG_NAMESPACE = 'queue';
5
+
6
+ export interface QueueConfigValues {
7
+ enabled: boolean;
8
+ redisUrl: string;
9
+ prefix: string;
10
+ defaultAttempts: number;
11
+ defaultBackoffMs: number;
12
+ }
13
+
14
+ export const queueConfig = registerAs(QUEUE_CONFIG_NAMESPACE, (): QueueConfigValues => {
15
+ const env = parseQueueEnv(process.env);
16
+
17
+ return {
18
+ enabled: env.QUEUE_ENABLED,
19
+ redisUrl: env.QUEUE_REDIS_URL,
20
+ prefix: env.QUEUE_PREFIX,
21
+ defaultAttempts: env.QUEUE_DEFAULT_ATTEMPTS,
22
+ defaultBackoffMs: env.QUEUE_DEFAULT_BACKOFF_MS,
23
+ };
24
+ });
@@ -0,0 +1,10 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { QueueConfigService } from './queue-config.service';
4
+
5
+ @Module({
6
+ imports: [ConfigModule],
7
+ providers: [QueueConfigService],
8
+ exports: [QueueConfigService],
9
+ })
10
+ export class QueueConfigModule {}
@@ -0,0 +1,69 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import type { ConnectionOptions } from 'bullmq';
4
+ import { QUEUE_CONFIG_NAMESPACE, QueueConfigValues } from './queue-config.loader';
5
+
6
+ @Injectable()
7
+ export class QueueConfigService {
8
+ constructor(private readonly configService: ConfigService) {}
9
+
10
+ get enabled(): QueueConfigValues['enabled'] {
11
+ return this.configService.getOrThrow<QueueConfigValues['enabled']>(
12
+ `${QUEUE_CONFIG_NAMESPACE}.enabled`,
13
+ );
14
+ }
15
+
16
+ get redisUrl(): QueueConfigValues['redisUrl'] {
17
+ return this.configService.getOrThrow<QueueConfigValues['redisUrl']>(
18
+ `${QUEUE_CONFIG_NAMESPACE}.redisUrl`,
19
+ );
20
+ }
21
+
22
+ get prefix(): QueueConfigValues['prefix'] {
23
+ return this.configService.getOrThrow<QueueConfigValues['prefix']>(
24
+ `${QUEUE_CONFIG_NAMESPACE}.prefix`,
25
+ );
26
+ }
27
+
28
+ get defaultAttempts(): QueueConfigValues['defaultAttempts'] {
29
+ return this.configService.getOrThrow<QueueConfigValues['defaultAttempts']>(
30
+ `${QUEUE_CONFIG_NAMESPACE}.defaultAttempts`,
31
+ );
32
+ }
33
+
34
+ get defaultBackoffMs(): QueueConfigValues['defaultBackoffMs'] {
35
+ return this.configService.getOrThrow<QueueConfigValues['defaultBackoffMs']>(
36
+ `${QUEUE_CONFIG_NAMESPACE}.defaultBackoffMs`,
37
+ );
38
+ }
39
+
40
+ get connectionOptions(): ConnectionOptions {
41
+ const parsedUrl = new URL(this.redisUrl);
42
+ const options: ConnectionOptions = {
43
+ host: parsedUrl.hostname,
44
+ port: parsedUrl.port ? Number(parsedUrl.port) : 6379,
45
+ };
46
+
47
+ if (parsedUrl.username) {
48
+ (options as { username?: string }).username = decodeURIComponent(parsedUrl.username);
49
+ }
50
+
51
+ if (parsedUrl.password) {
52
+ (options as { password?: string }).password = decodeURIComponent(parsedUrl.password);
53
+ }
54
+
55
+ const dbPath = parsedUrl.pathname.replace(/^\//, '');
56
+ if (dbPath.length > 0) {
57
+ const dbValue = Number(dbPath);
58
+ if (Number.isInteger(dbValue) && dbValue >= 0) {
59
+ (options as { db?: number }).db = dbValue;
60
+ }
61
+ }
62
+
63
+ if (parsedUrl.protocol === 'rediss:') {
64
+ (options as { tls?: Record<string, never> }).tls = {};
65
+ }
66
+
67
+ return options;
68
+ }
69
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+
3
+ export const queueEnvSchema = z
4
+ .object({
5
+ QUEUE_ENABLED: z.coerce.boolean().default(true),
6
+ QUEUE_REDIS_URL: z.string().trim().min(1).default('redis://localhost:6379'),
7
+ QUEUE_PREFIX: z.string().trim().min(1).default('forgeon'),
8
+ QUEUE_DEFAULT_ATTEMPTS: z.coerce.number().int().positive().default(3),
9
+ QUEUE_DEFAULT_BACKOFF_MS: z.coerce.number().int().nonnegative().default(1000),
10
+ })
11
+ .passthrough();
12
+
13
+ export type QueueEnv = z.infer<typeof queueEnvSchema>;
14
+
15
+ export function parseQueueEnv(input: Record<string, unknown>): QueueEnv {
16
+ return queueEnvSchema.parse(input);
17
+ }
@@ -0,0 +1,88 @@
1
+ import { Injectable, OnModuleDestroy } from '@nestjs/common';
2
+ import type { JobsOptions } from 'bullmq';
3
+ import { Queue } from 'bullmq';
4
+ import { QueueConfigService } from './queue-config.service';
5
+
6
+ export const FORGEON_DEFAULT_QUEUE = 'forgeon.default';
7
+
8
+ @Injectable()
9
+ export class QueueService implements OnModuleDestroy {
10
+ private queue: Queue | null = null;
11
+
12
+ constructor(private readonly queueConfig: QueueConfigService) {}
13
+
14
+ async enqueue(
15
+ name: string,
16
+ data: Record<string, unknown>,
17
+ options: JobsOptions = {},
18
+ ): Promise<{ queued: boolean; id: string | null }> {
19
+ if (!this.queueConfig.enabled) {
20
+ return { queued: false, id: null };
21
+ }
22
+
23
+ const job = await this.getQueue().add(name, data, {
24
+ attempts: this.queueConfig.defaultAttempts,
25
+ backoff: {
26
+ type: 'fixed',
27
+ delay: this.queueConfig.defaultBackoffMs,
28
+ },
29
+ ...options,
30
+ });
31
+
32
+ return {
33
+ queued: true,
34
+ id: job.id == null ? null : String(job.id),
35
+ };
36
+ }
37
+
38
+ async getProbeStatus(): Promise<Record<string, unknown>> {
39
+ if (!this.queueConfig.enabled) {
40
+ return {
41
+ status: 'ok',
42
+ feature: 'queue',
43
+ enabled: false,
44
+ };
45
+ }
46
+
47
+ try {
48
+ const queue = this.getQueue();
49
+ const client = await queue.client;
50
+ const ping = await client.ping();
51
+ return {
52
+ status: 'ok',
53
+ feature: 'queue',
54
+ enabled: true,
55
+ queueName: FORGEON_DEFAULT_QUEUE,
56
+ prefix: this.queueConfig.prefix,
57
+ redis: ping,
58
+ };
59
+ } catch (error) {
60
+ return {
61
+ status: 'error',
62
+ feature: 'queue',
63
+ enabled: true,
64
+ queueName: FORGEON_DEFAULT_QUEUE,
65
+ message: error instanceof Error ? error.message : 'Queue probe failed',
66
+ };
67
+ }
68
+ }
69
+
70
+ async onModuleDestroy(): Promise<void> {
71
+ if (!this.queue) {
72
+ return;
73
+ }
74
+
75
+ await this.queue.close();
76
+ this.queue = null;
77
+ }
78
+
79
+ private getQueue(): Queue {
80
+ if (!this.queue) {
81
+ this.queue = new Queue(FORGEON_DEFAULT_QUEUE, {
82
+ prefix: this.queueConfig.prefix,
83
+ connection: this.queueConfig.connectionOptions,
84
+ });
85
+ }
86
+ return this.queue;
87
+ }
88
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.node.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
@@ -1,3 +0,0 @@
1
- ## Current State
2
-
3
- This module is registered but not implemented yet.