create-forgeon 0.1.23 → 0.1.25

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/core/docs.mjs +12 -9
  3. package/src/core/docs.test.mjs +26 -20
  4. package/src/modules/executor.test.mjs +8 -0
  5. package/src/modules/i18n.mjs +19 -1
  6. package/src/presets/i18n.mjs +12 -11
  7. package/templates/base/apps/api/Dockerfile +7 -4
  8. package/templates/base/apps/api/package.json +3 -2
  9. package/templates/base/apps/api/src/app.module.ts +4 -4
  10. package/templates/base/apps/api/src/main.ts +6 -8
  11. package/templates/base/docs/AI/ARCHITECTURE.md +48 -32
  12. package/templates/base/docs/AI/PROJECT.md +27 -16
  13. package/templates/base/docs/AI/VALIDATION.md +6 -1
  14. package/templates/base/packages/core/README.md +1 -0
  15. package/templates/base/packages/core/src/errors/core-exception.filter.ts +11 -3
  16. package/templates/base/packages/core/src/index.ts +1 -0
  17. package/templates/base/packages/core/src/validation/core-validation.pipe.ts +56 -0
  18. package/templates/base/packages/core/src/validation/index.ts +1 -0
  19. package/templates/base/packages/db-prisma/README.md +9 -0
  20. package/templates/base/packages/db-prisma/package.json +20 -0
  21. package/templates/base/packages/db-prisma/src/db-prisma-config.loader.ts +18 -0
  22. package/templates/base/packages/db-prisma/src/db-prisma-config.service.ts +12 -0
  23. package/templates/base/packages/db-prisma/src/db-prisma-env.schema.ts +17 -0
  24. package/templates/base/packages/db-prisma/src/db-prisma.module.ts +13 -0
  25. package/templates/base/packages/db-prisma/src/index.ts +5 -0
  26. package/templates/base/{apps/api/src/prisma → packages/db-prisma/src}/prisma.service.ts +24 -27
  27. package/templates/base/packages/db-prisma/tsconfig.json +9 -0
  28. package/templates/docs-fragments/AI_ARCHITECTURE/20_env_base.md +1 -1
  29. package/templates/docs-fragments/AI_ARCHITECTURE/23_error_handling.md +11 -0
  30. package/templates/docs-fragments/AI_ARCHITECTURE/30_default_db.md +9 -7
  31. package/templates/docs-fragments/AI_ARCHITECTURE/32_scope_freeze.md +6 -5
  32. package/templates/docs-fragments/AI_PROJECT/20_structure_base.md +2 -1
  33. package/templates/docs-fragments/AI_PROJECT/34_error_handling.md +7 -0
  34. package/templates/docs-fragments/README/41_error_handling.md +27 -0
  35. package/templates/base/apps/api/src/prisma/prisma.module.ts +0 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-forgeon",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Forgeon project generator CLI",
5
5
  "license": "MIT",
6
6
  "author": "Forgeon",
package/src/core/docs.mjs CHANGED
@@ -64,11 +64,12 @@ export function generateDocs(targetRoot, options, packageRoot) {
64
64
  } else {
65
65
  readmeFragments.push('31_proxy_preset_none');
66
66
  }
67
- readmeFragments.push('32_prisma_container_start');
68
- if (options.i18nEnabled) {
69
- readmeFragments.push('40_i18n');
70
- }
71
- readmeFragments.push('90_next_steps');
67
+ readmeFragments.push('32_prisma_container_start');
68
+ if (options.i18nEnabled) {
69
+ readmeFragments.push('40_i18n');
70
+ }
71
+ readmeFragments.push('41_error_handling');
72
+ readmeFragments.push('90_next_steps');
72
73
 
73
74
  const aiProjectFragments = ['00_title', '10_what_is', '20_structure_base'];
74
75
  if (options.i18nEnabled) {
@@ -80,10 +81,11 @@ export function generateDocs(targetRoot, options, packageRoot) {
80
81
  } else {
81
82
  aiProjectFragments.push('32_proxy_notes');
82
83
  }
83
- if (options.i18nEnabled) {
84
- aiProjectFragments.push('33_i18n_notes');
85
- }
86
- aiProjectFragments.push('40_change_boundaries_base');
84
+ if (options.i18nEnabled) {
85
+ aiProjectFragments.push('33_i18n_notes');
86
+ }
87
+ aiProjectFragments.push('34_error_handling');
88
+ aiProjectFragments.push('40_change_boundaries_base');
87
89
  if (options.proxy !== 'none') {
88
90
  aiProjectFragments.push('41_change_boundaries_docker');
89
91
  }
@@ -97,6 +99,7 @@ export function generateDocs(targetRoot, options, packageRoot) {
97
99
  aiArchitectureFragments.push('21_env_i18n');
98
100
  }
99
101
  aiArchitectureFragments.push('22_ts_module_policy');
102
+ aiArchitectureFragments.push('23_error_handling');
100
103
  aiArchitectureFragments.push('30_default_db', '31_docker_runtime', '32_scope_freeze');
101
104
  aiArchitectureFragments.push('40_docs_generation', '50_extension_points');
102
105
 
@@ -38,14 +38,16 @@ describe('generateDocs', () => {
38
38
  const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
39
39
  const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
40
40
 
41
- assert.match(readme, /Docker\/infra: `enabled`/);
42
- assert.match(readme, /Quick Start \(Docker\)/);
43
- assert.match(readme, /Proxy Preset: none/);
44
- assert.doesNotMatch(readme, /i18n Configuration/);
45
-
46
- assert.match(projectDoc, /### Docker mode/);
47
- assert.match(projectDoc, /Active proxy preset: `none`/);
48
- assert.doesNotMatch(projectDoc, /packages\/i18n/);
41
+ assert.match(readme, /Docker\/infra: `enabled`/);
42
+ assert.match(readme, /Quick Start \(Docker\)/);
43
+ assert.match(readme, /Proxy Preset: none/);
44
+ assert.match(readme, /Error Handling \(`core-errors`\)/);
45
+ assert.doesNotMatch(readme, /i18n Configuration/);
46
+
47
+ assert.match(projectDoc, /### Docker mode/);
48
+ assert.match(projectDoc, /Active proxy preset: `none`/);
49
+ assert.match(projectDoc, /CoreErrorsModule/);
50
+ assert.doesNotMatch(projectDoc, /packages\/i18n/);
49
51
 
50
52
  assert.match(architectureDoc, /infra\/\*/);
51
53
  assert.doesNotMatch(architectureDoc, /I18N_ENABLED/);
@@ -53,9 +55,10 @@ describe('generateDocs', () => {
53
55
  assert.match(architectureDoc, /Config Strategy/);
54
56
  assert.match(architectureDoc, /TypeScript Module Policy/);
55
57
  assert.match(architectureDoc, /tsconfig\.base\.esm\.json/);
56
- } finally {
57
- fs.rmSync(targetRoot, { recursive: true, force: true });
58
- }
58
+ assert.match(architectureDoc, /DbPrismaModule/);
59
+ } finally {
60
+ fs.rmSync(targetRoot, { recursive: true, force: true });
61
+ }
59
62
  });
60
63
 
61
64
  it('generates docker and caddy notes when enabled', () => {
@@ -78,12 +81,14 @@ describe('generateDocs', () => {
78
81
  const projectDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'PROJECT.md'));
79
82
  const architectureDoc = readFile(path.join(targetRoot, 'docs', 'AI', 'ARCHITECTURE.md'));
80
83
 
81
- assert.match(readme, /Quick Start \(Docker\)/);
82
- assert.match(readme, /Proxy Preset: Caddy/);
83
- assert.match(readme, /i18n Configuration/);
84
-
85
- assert.match(projectDoc, /`infra` - Docker Compose \(always\) \+ proxy preset \(`caddy`\)/);
86
- assert.match(projectDoc, /Main proxy config: `infra\/caddy\/Caddyfile`/);
84
+ assert.match(readme, /Quick Start \(Docker\)/);
85
+ assert.match(readme, /Proxy Preset: Caddy/);
86
+ assert.match(readme, /i18n Configuration/);
87
+ assert.match(readme, /Error Handling \(`core-errors`\)/);
88
+
89
+ assert.match(projectDoc, /`infra` - Docker Compose \(always\) \+ proxy preset \(`caddy`\)/);
90
+ assert.match(projectDoc, /Main proxy config: `infra\/caddy\/Caddyfile`/);
91
+ assert.match(projectDoc, /CoreExceptionFilter/);
87
92
 
88
93
  assert.match(architectureDoc, /infra\/\*/);
89
94
  assert.match(architectureDoc, /I18N_DEFAULT_LANG/);
@@ -91,8 +96,9 @@ describe('generateDocs', () => {
91
96
  assert.match(architectureDoc, /Active reverse proxy preset: `caddy`/);
92
97
  assert.match(architectureDoc, /TypeScript Module Policy/);
93
98
  assert.match(architectureDoc, /tsconfig\.base\.node\.json/);
94
- } finally {
95
- fs.rmSync(targetRoot, { recursive: true, force: true });
96
- }
99
+ assert.match(architectureDoc, /DbPrismaModule/);
100
+ } finally {
101
+ fs.rmSync(targetRoot, { recursive: true, force: true });
102
+ }
97
103
  });
98
104
  });
@@ -98,6 +98,7 @@ describe('addModule', () => {
98
98
  assert.equal(fs.existsSync(path.join(projectRoot, 'tsconfig.base.esm.json')), true);
99
99
 
100
100
  const apiPackage = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'package.json'), 'utf8');
101
+ assert.match(apiPackage, /@forgeon\/db-prisma/);
101
102
  assert.match(apiPackage, /@forgeon\/i18n/);
102
103
  assert.match(apiPackage, /@forgeon\/i18n-contracts/);
103
104
 
@@ -110,15 +111,20 @@ describe('addModule', () => {
110
111
 
111
112
  const appModule = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'app.module.ts'), 'utf8');
112
113
  assert.match(appModule, /coreConfig/);
114
+ assert.match(appModule, /dbPrismaConfig/);
115
+ assert.match(appModule, /dbPrismaEnvSchema/);
113
116
  assert.match(appModule, /createEnvValidator/);
114
117
  assert.match(appModule, /coreEnvSchema/);
115
118
  assert.match(appModule, /i18nConfig/);
116
119
  assert.match(appModule, /i18nEnvSchema/);
117
120
  assert.match(appModule, /CoreConfigModule/);
118
121
  assert.match(appModule, /CoreErrorsModule/);
122
+ assert.match(appModule, /DbPrismaModule/);
119
123
 
120
124
  const mainTs = fs.readFileSync(path.join(projectRoot, 'apps', 'api', 'src', 'main.ts'), 'utf8');
121
125
  assert.match(mainTs, /CoreExceptionFilter/);
126
+ assert.match(mainTs, /createValidationPipe/);
127
+ assert.doesNotMatch(mainTs, /new ValidationPipe\(/);
122
128
 
123
129
  const forgeonI18nModule = fs.readFileSync(
124
130
  path.join(projectRoot, 'packages', 'i18n', 'src', 'forgeon-i18n.module.ts'),
@@ -225,6 +231,8 @@ describe('addModule', () => {
225
231
  'utf8',
226
232
  );
227
233
  assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/core build/);
234
+ assert.match(apiDockerfile, /RUN pnpm --filter @forgeon\/db-prisma build/);
235
+ assert.match(apiDockerfile, /COPY packages\/db-prisma\/package\.json packages\/db-prisma\/package\.json/);
228
236
  } finally {
229
237
  fs.rmSync(targetRoot, { recursive: true, force: true });
230
238
  }
@@ -102,6 +102,11 @@ function patchApiDockerfile(targetRoot) {
102
102
  content = ensureLineAfter(
103
103
  content,
104
104
  'COPY packages/core/package.json packages/core/package.json',
105
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
106
+ );
107
+ content = ensureLineAfter(
108
+ content,
109
+ 'COPY packages/db-prisma/package.json packages/db-prisma/package.json',
105
110
  'COPY packages/i18n-contracts/package.json packages/i18n-contracts/package.json',
106
111
  );
107
112
  content = ensureLineAfter(
@@ -112,6 +117,11 @@ function patchApiDockerfile(targetRoot) {
112
117
  content = ensureLineAfter(
113
118
  content,
114
119
  'COPY packages/core packages/core',
120
+ 'COPY packages/db-prisma packages/db-prisma',
121
+ );
122
+ content = ensureLineAfter(
123
+ content,
124
+ 'COPY packages/db-prisma packages/db-prisma',
115
125
  'COPY packages/i18n-contracts packages/i18n-contracts',
116
126
  );
117
127
  content = ensureLineAfter(
@@ -122,6 +132,7 @@ function patchApiDockerfile(targetRoot) {
122
132
 
123
133
  content = content
124
134
  .replace(/^RUN pnpm --filter @forgeon\/core build\r?\n?/gm, '')
135
+ .replace(/^RUN pnpm --filter @forgeon\/db-prisma build\r?\n?/gm, '')
125
136
  .replace(/^RUN pnpm --filter @forgeon\/i18n-contracts build\r?\n?/gm, '')
126
137
  .replace(/^RUN pnpm --filter @forgeon\/i18n build\r?\n?/gm, '');
127
138
 
@@ -130,6 +141,11 @@ function patchApiDockerfile(targetRoot) {
130
141
  'RUN pnpm --filter @forgeon/api prisma:generate',
131
142
  'RUN pnpm --filter @forgeon/core build',
132
143
  );
144
+ content = ensureLineBefore(
145
+ content,
146
+ 'RUN pnpm --filter @forgeon/api prisma:generate',
147
+ 'RUN pnpm --filter @forgeon/db-prisma build',
148
+ );
133
149
  content = ensureLineBefore(
134
150
  content,
135
151
  'RUN pnpm --filter @forgeon/api prisma:generate',
@@ -229,10 +245,11 @@ function patchApiPackage(targetRoot) {
229
245
  ensureScript(
230
246
  packageJson,
231
247
  'predev',
232
- 'pnpm --filter @forgeon/i18n-contracts build && pnpm --filter @forgeon/i18n build',
248
+ 'pnpm --filter @forgeon/core build && pnpm --filter @forgeon/db-prisma build && pnpm --filter @forgeon/i18n-contracts build && pnpm --filter @forgeon/i18n build',
233
249
  );
234
250
  ensureDependency(packageJson, '@forgeon/i18n', 'workspace:*');
235
251
  ensureDependency(packageJson, '@forgeon/i18n-contracts', 'workspace:*');
252
+ ensureDependency(packageJson, '@forgeon/db-prisma', 'workspace:*');
236
253
  ensureDependency(packageJson, 'nestjs-i18n', '^10.5.1');
237
254
  writeJson(packagePath, packageJson);
238
255
  }
@@ -298,6 +315,7 @@ function patchRootPackage(targetRoot) {
298
315
  }
299
316
 
300
317
  export function applyI18nModule({ packageRoot, targetRoot }) {
318
+ copyFromBase(packageRoot, targetRoot, path.join('packages', 'db-prisma'));
301
319
  copyFromBase(packageRoot, targetRoot, path.join('packages', 'i18n'));
302
320
  copyFromBase(packageRoot, targetRoot, path.join('resources', 'i18n'));
303
321
 
@@ -9,12 +9,13 @@ export function applyI18nDisabled(targetRoot) {
9
9
  removeIfExists(path.join(targetRoot, 'resources', 'i18n'));
10
10
 
11
11
  const apiPackagePath = path.join(targetRoot, 'apps', 'api', 'package.json');
12
- if (fs.existsSync(apiPackagePath)) {
13
- const apiPackage = JSON.parse(fs.readFileSync(apiPackagePath, 'utf8'));
14
-
15
- if (apiPackage.scripts) {
16
- delete apiPackage.scripts.predev;
17
- }
12
+ if (fs.existsSync(apiPackagePath)) {
13
+ const apiPackage = JSON.parse(fs.readFileSync(apiPackagePath, 'utf8'));
14
+
15
+ if (apiPackage.scripts) {
16
+ apiPackage.scripts.predev =
17
+ 'pnpm --filter @forgeon/core build && pnpm --filter @forgeon/db-prisma build';
18
+ }
18
19
 
19
20
  if (apiPackage.dependencies) {
20
21
  delete apiPackage.dependencies['@forgeon/i18n'];
@@ -110,21 +111,21 @@ export function applyI18nDisabled(targetRoot) {
110
111
  appModulePath,
111
112
  `import { Module } from '@nestjs/common';
112
113
  import { ConfigModule } from '@nestjs/config';
113
- import { CoreConfigModule, CoreErrorsModule, coreConfig, validateCoreEnv } from '@forgeon/core';
114
+ import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';
115
+ import { CoreConfigModule, CoreErrorsModule, coreConfig, coreEnvSchema, createEnvValidator } from '@forgeon/core';
114
116
  import { HealthController } from './health/health.controller';
115
- import { PrismaModule } from './prisma/prisma.module';
116
117
 
117
118
  @Module({
118
119
  imports: [
119
120
  ConfigModule.forRoot({
120
121
  isGlobal: true,
121
- load: [coreConfig],
122
- validate: validateCoreEnv,
122
+ load: [coreConfig, dbPrismaConfig],
123
+ validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema]),
123
124
  envFilePath: '.env',
124
125
  }),
125
126
  CoreConfigModule,
126
127
  CoreErrorsModule,
127
- PrismaModule,
128
+ DbPrismaModule,
128
129
  ],
129
130
  controllers: [HealthController],
130
131
  })
@@ -4,19 +4,22 @@ WORKDIR /app
4
4
  RUN corepack enable
5
5
 
6
6
  COPY package.json pnpm-workspace.yaml tsconfig.base.json tsconfig.base.node.json tsconfig.base.esm.json ./
7
- COPY apps/api/package.json apps/api/package.json
8
- COPY apps/api/prisma apps/api/prisma
9
- COPY packages/core/package.json packages/core/package.json
10
- COPY packages/i18n/package.json packages/i18n/package.json
7
+ COPY apps/api/package.json apps/api/package.json
8
+ COPY apps/api/prisma apps/api/prisma
9
+ COPY packages/core/package.json packages/core/package.json
10
+ COPY packages/db-prisma/package.json packages/db-prisma/package.json
11
+ COPY packages/i18n/package.json packages/i18n/package.json
11
12
 
12
13
  RUN pnpm install --frozen-lockfile=false
13
14
 
14
15
  COPY apps/api apps/api
15
16
  COPY packages/core packages/core
17
+ COPY packages/db-prisma packages/db-prisma
16
18
  COPY packages/i18n packages/i18n
17
19
  COPY resources resources
18
20
 
19
21
  RUN pnpm --filter @forgeon/core build
22
+ RUN pnpm --filter @forgeon/db-prisma build
20
23
  RUN pnpm --filter @forgeon/i18n build
21
24
  RUN pnpm --filter @forgeon/api prisma:generate
22
25
  RUN pnpm --filter @forgeon/api build
@@ -2,8 +2,8 @@
2
2
  "name": "@forgeon/api",
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
- "scripts": {
6
- "predev": "pnpm --filter @forgeon/i18n build",
5
+ "scripts": {
6
+ "predev": "pnpm --filter @forgeon/core build && pnpm --filter @forgeon/db-prisma build && pnpm --filter @forgeon/i18n build",
7
7
  "build": "tsc -p tsconfig.build.json",
8
8
  "dev": "ts-node --transpile-only src/main.ts",
9
9
  "start": "node dist/main.js",
@@ -14,6 +14,7 @@
14
14
  "prisma:seed": "ts-node --transpile-only prisma/seed.ts"
15
15
  },
16
16
  "dependencies": {
17
+ "@forgeon/db-prisma": "workspace:*",
17
18
  "@forgeon/core": "workspace:*",
18
19
  "@forgeon/i18n": "workspace:*",
19
20
  "@nestjs/common": "^11.0.1",
@@ -1,5 +1,6 @@
1
1
  import { Module } from '@nestjs/common';
2
2
  import { ConfigModule } from '@nestjs/config';
3
+ import { dbPrismaConfig, dbPrismaEnvSchema, DbPrismaModule } from '@forgeon/db-prisma';
3
4
  import {
4
5
  CoreConfigModule,
5
6
  CoreErrorsModule,
@@ -10,7 +11,6 @@ import {
10
11
  import { ForgeonI18nModule, i18nConfig, i18nEnvSchema } from '@forgeon/i18n';
11
12
  import { join } from 'path';
12
13
  import { HealthController } from './health/health.controller';
13
- import { PrismaModule } from './prisma/prisma.module';
14
14
 
15
15
  const i18nPath = join(__dirname, '..', '..', '..', 'resources', 'i18n');
16
16
 
@@ -18,16 +18,16 @@ const i18nPath = join(__dirname, '..', '..', '..', 'resources', 'i18n');
18
18
  imports: [
19
19
  ConfigModule.forRoot({
20
20
  isGlobal: true,
21
- load: [coreConfig, i18nConfig],
22
- validate: createEnvValidator([coreEnvSchema, i18nEnvSchema]),
21
+ load: [coreConfig, dbPrismaConfig, i18nConfig],
22
+ validate: createEnvValidator([coreEnvSchema, dbPrismaEnvSchema, i18nEnvSchema]),
23
23
  envFilePath: '.env',
24
24
  }),
25
25
  CoreConfigModule,
26
26
  CoreErrorsModule,
27
+ DbPrismaModule,
27
28
  ForgeonI18nModule.register({
28
29
  path: i18nPath,
29
30
  }),
30
- PrismaModule,
31
31
  ],
32
32
  controllers: [HealthController],
33
33
  })
@@ -1,6 +1,9 @@
1
1
  import 'reflect-metadata';
2
- import { ValidationPipe } from '@nestjs/common';
3
- import { CoreConfigService, CoreExceptionFilter } from '@forgeon/core';
2
+ import {
3
+ CoreConfigService,
4
+ CoreExceptionFilter,
5
+ createValidationPipe,
6
+ } from '@forgeon/core';
4
7
  import { NestFactory } from '@nestjs/core';
5
8
  import { AppModule } from './app.module';
6
9
 
@@ -10,12 +13,7 @@ async function bootstrap() {
10
13
  const coreConfigService = app.get(CoreConfigService);
11
14
 
12
15
  app.setGlobalPrefix(coreConfigService.apiPrefix);
13
- app.useGlobalPipes(
14
- new ValidationPipe({
15
- whitelist: true,
16
- transform: true,
17
- }),
18
- );
16
+ app.useGlobalPipes(createValidationPipe());
19
17
  app.useGlobalFilters(app.get(CoreExceptionFilter));
20
18
 
21
19
  await app.listen(coreConfigService.port);
@@ -1,11 +1,15 @@
1
- # ARCHITECTURE
2
-
3
- ## Monorepo Layout
4
-
5
- - `apps/*` - deployable apps
6
- - `packages/*` - reusable modules/presets
7
- - `infra/*` - runtime infrastructure
8
- - `resources/*` - static assets (translations)
1
+ # ARCHITECTURE
2
+
3
+ ## Monorepo Layout
4
+
5
+ - `apps/*` - deployable apps
6
+ - `packages/*` - reusable modules/presets
7
+ - `infra/*` - runtime infrastructure
8
+ - `resources/*` - static assets (translations)
9
+
10
+ Canonical stack is fixed in this stage:
11
+ - NestJS + React + Prisma/Postgres + Docker
12
+ - Proxy preset can be `caddy`, `nginx`, or `none`
9
13
 
10
14
  ## Environment Flags
11
15
 
@@ -17,36 +21,48 @@
17
21
 
18
22
  ## Config Strategy
19
23
 
20
- - `@forgeon/core` owns base runtime config (port, API prefix, node env).
24
+ - `@forgeon/core` owns base runtime config, global error envelope/filter, and validation pipe defaults.
21
25
  - Core config is validated with Zod and exposed through typed accessors.
22
26
  - Add-modules own and validate only their module-specific env keys.
23
27
  - i18n is an add-module; when installed, it uses its own env keys.
24
-
25
- ## Default DB Stack
26
-
27
- Current default is Prisma + Postgres.
28
-
29
- - Prisma schema and migrations live in `apps/api/prisma`
30
- - DB access is encapsulated via `PrismaModule` (`apps/api/src/prisma`)
31
-
32
- ## Future DB Presets (Not Implemented Yet)
33
-
34
- A future preset can switch DB by:
35
- 1. Replacing `PrismaModule` with another DB module package (for example Mongo package).
36
- 2. Updating `infra/docker/compose.yml` DB service.
37
- 3. Updating `DATABASE_URL` and related env keys.
38
- 4. Keeping app-level services dependent only on repository/data-access abstractions.
39
-
40
- ## Future Feature Modules
41
28
 
42
- Reusable features should be added as workspace packages and imported by apps as needed:
29
+ ## Default DB Stack
30
+
31
+ Current default is Prisma + Postgres.
32
+
33
+ - Prisma schema and migrations live in `apps/api/prisma`
34
+ - DB access is encapsulated via `DbPrismaModule` in `@forgeon/db-prisma`
35
+ - `db-prisma` is treated as default-applied behavior in scaffold generation.
36
+ - Future direction: this default DB layer may be extracted to an explicit add-module/preset and optionally controlled by a CLI flag.
37
+ - Additional DB presets are out of scope for the current milestone.
38
+
39
+ ## Module Strategy
43
40
 
44
- - `packages/core` for shared backend primitives
45
- - Additional packages for auth presets, guards, queues, mailers, etc.
41
+ Reusable features should be added as fullstack add-modules:
42
+
43
+ - `contracts` package (shared DTO/routes/errors)
44
+ - `api` package (NestJS integration)
45
+ - `web` package (React integration)
46
+
47
+ Reference: `docs/AI/MODULE_SPEC.md`.
46
48
 
47
49
  ## TypeScript Module Format Policy
48
50
 
49
- - `apps/api`, `packages/core`, and backend runtime packages use `tsconfig.base.node.json`.
50
- - Frontend-consumed shared packages (contracts/web helpers) use `tsconfig.base.esm.json`.
51
- - Contracts packages are ESM-first and imported only via package entrypoints.
51
+ - `apps/api`, `packages/core`, and backend runtime packages use Node-oriented config:
52
+ - `tsconfig.base.node.json`
53
+ - Frontend-consumed shared packages (especially contracts/web helpers) use ESM config:
54
+ - `tsconfig.base.esm.json`
55
+ - Contracts packages are ESM-first and imported via package entrypoints only.
52
56
  - Cross-package imports from `/src/*` are disallowed.
57
+
58
+ ## Error Handling Strategy
59
+
60
+ - `@forgeon/core` owns the global HTTP error envelope and filter.
61
+ - API apps import `CoreErrorsModule` and register `CoreExceptionFilter` globally.
62
+ - Envelope fields:
63
+ - `error.code`
64
+ - `error.message`
65
+ - `error.status`
66
+ - `error.details` (optional)
67
+ - `error.requestId` (optional)
68
+ - `error.timestamp`
@@ -4,15 +4,16 @@
4
4
 
5
5
  A canonical fullstack monorepo scaffold intended to be reused as a project starter.
6
6
 
7
- ## Structure
8
-
9
- - `apps/api` - NestJS backend
10
- - `apps/web` - frontend scaffold (default React + Vite + TS)
11
- - `packages/core` - shared backend core package with internal submodules (starting with `core-config`)
12
- - `packages/i18n` - reusable nestjs-i18n integration package
13
- - `infra` - Docker Compose + reverse proxy preset (nginx/caddy)
14
- - `resources/i18n` - translation dictionaries
15
- - `docs` - documentation and AI workflow prompts
7
+ ## Structure
8
+
9
+ - `apps/api` - NestJS backend
10
+ - `apps/web` - React frontend (fixed stack)
11
+ - `packages/core` - shared backend core package (`core-config`, `core-errors`, `core-validation`)
12
+ - `packages/db-prisma` - reusable Prisma/Postgres module (`DbPrismaModule`, `PrismaService`, config)
13
+ - `packages/i18n` - reusable nestjs-i18n integration package
14
+ - `infra` - Docker Compose + proxy preset (`caddy|nginx|none`)
15
+ - `resources/i18n` - translation dictionaries
16
+ - `docs` - documentation, AI prompts, and module contracts
16
17
 
17
18
  ## Run Modes
18
19
 
@@ -23,10 +24,20 @@ pnpm install
23
24
  pnpm dev
24
25
  ```
25
26
 
26
- ### Docker mode
27
-
28
- ```bash
29
- docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build
30
- ```
31
-
32
- The API uses Prisma and expects `DATABASE_URL` from env.
27
+ ### Docker mode
28
+
29
+ ```bash
30
+ docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build
31
+ ```
32
+
33
+ The API uses Prisma and expects `DATABASE_URL` from env.
34
+
35
+ If proxy preset is `none`, API is directly available on `localhost:3000`.
36
+
37
+ ## Error Handling
38
+
39
+ `core-errors` is enabled by default.
40
+
41
+ - `CoreErrorsModule` is imported in `apps/api/src/app.module.ts`.
42
+ - `CoreExceptionFilter` is registered globally in `apps/api/src/main.ts`.
43
+ - Throw standard Nest exceptions from controllers/services; the filter converts them to a stable envelope.
@@ -3,9 +3,12 @@
3
3
  ## Backend DTO Validation Standard
4
4
 
5
5
  - Use `class-validator` decorators on DTO classes.
6
- - Global validation is done by Nest `ValidationPipe` with:
6
+ - Global validation is centralized in `@forgeon/core` via `createValidationPipe()`.
7
+ - Current defaults:
7
8
  - `whitelist: true`
8
9
  - `transform: true`
10
+ - `validationError.target: false`
11
+ - `validationError.value: false`
9
12
  - Keep DTO validation messages stable and explicit.
10
13
  - For required values, use a consistent key or message pattern.
11
14
 
@@ -24,3 +27,5 @@
24
27
  - `error.status`
25
28
  - optional `error.details`
26
29
  - Validation details should be structured (not `any`).
30
+ - `core-validation` formats validation details as:
31
+ - `{ field?: string, message: string }[]`
@@ -6,4 +6,5 @@ Current submodules:
6
6
 
7
7
  - `core-config` - Zod-validated env config + typed accessors for API runtime.
8
8
  - `core-errors` - global exception filter + stable error envelope shape.
9
+ - `core-validation` - shared ValidationPipe factory with normalized validation details.
9
10
 
@@ -88,15 +88,23 @@ export class CoreExceptionFilter implements ExceptionFilter {
88
88
  }
89
89
 
90
90
  private resolveDetails(payload: unknown, status: number): AppErrorDetails | undefined {
91
- if (status !== HttpStatus.BAD_REQUEST) {
91
+ if (typeof payload !== 'object' || payload === null) {
92
92
  return undefined;
93
93
  }
94
94
 
95
- if (typeof payload !== 'object' || payload === null) {
95
+ const obj = payload as { message?: unknown; details?: unknown };
96
+
97
+ if (Array.isArray(obj.details)) {
98
+ return obj.details as AppErrorDetails;
99
+ }
100
+ if (obj.details && typeof obj.details === 'object') {
101
+ return obj.details as AppErrorDetails;
102
+ }
103
+
104
+ if (status !== HttpStatus.BAD_REQUEST) {
96
105
  return undefined;
97
106
  }
98
107
 
99
- const obj = payload as { message?: unknown };
100
108
  const messages = Array.isArray(obj.message)
101
109
  ? obj.message.filter((item): item is string => typeof item === 'string')
102
110
  : typeof obj.message === 'string'
@@ -1,2 +1,3 @@
1
1
  export * from './config';
2
2
  export * from './errors';
3
+ export * from './validation';
@@ -0,0 +1,56 @@
1
+ import { BadRequestException, ValidationPipe } from '@nestjs/common';
2
+ import type { ValidationErrorDetail } from '../errors';
3
+
4
+ type ValidationNode = {
5
+ property?: string;
6
+ constraints?: Record<string, string>;
7
+ children?: ValidationNode[];
8
+ };
9
+
10
+ function toPath(parentPath: string, property: string | undefined): string {
11
+ if (!property || property.length === 0) {
12
+ return parentPath;
13
+ }
14
+ return parentPath.length > 0 ? `${parentPath}.${property}` : property;
15
+ }
16
+
17
+ function collectValidationDetails(
18
+ errors: ValidationNode[],
19
+ parentPath = '',
20
+ ): ValidationErrorDetail[] {
21
+ const details: ValidationErrorDetail[] = [];
22
+
23
+ for (const error of errors) {
24
+ const field = toPath(parentPath, error.property);
25
+ const constraints = error.constraints ? Object.values(error.constraints) : [];
26
+
27
+ for (const message of constraints) {
28
+ details.push(field.length > 0 ? { field, message } : { message });
29
+ }
30
+
31
+ if (Array.isArray(error.children) && error.children.length > 0) {
32
+ details.push(...collectValidationDetails(error.children, field));
33
+ }
34
+ }
35
+
36
+ return details;
37
+ }
38
+
39
+ export function createValidationPipe(): ValidationPipe {
40
+ return new ValidationPipe({
41
+ whitelist: true,
42
+ transform: true,
43
+ validationError: {
44
+ target: false,
45
+ value: false,
46
+ },
47
+ exceptionFactory: (errors) => {
48
+ const details = collectValidationDetails(errors as ValidationNode[]);
49
+ const firstMessage = details[0]?.message ?? 'Validation failed';
50
+ return new BadRequestException({
51
+ message: firstMessage,
52
+ details,
53
+ });
54
+ },
55
+ });
56
+ }
@@ -0,0 +1 @@
1
+ export * from './core-validation.pipe';
@@ -0,0 +1,9 @@
1
+ # @forgeon/db-prisma
2
+
3
+ Reusable Prisma/Postgres backend module.
4
+
5
+ Includes:
6
+
7
+ - `DbPrismaModule`
8
+ - `PrismaService`
9
+ - module-local config loader/service and env schema validation
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@forgeon/db-prisma",
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
+ "@prisma/client": "^6.18.0",
14
+ "zod": "^3.23.8"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.10.7",
18
+ "typescript": "^5.7.3"
19
+ }
20
+ }
@@ -0,0 +1,18 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { parseDbPrismaEnv } from './db-prisma-env.schema';
3
+
4
+ export const DB_PRISMA_CONFIG_NAMESPACE = 'dbPrisma';
5
+
6
+ export interface DbPrismaConfigValues {
7
+ databaseUrl: string;
8
+ }
9
+
10
+ export const dbPrismaConfig = registerAs(
11
+ DB_PRISMA_CONFIG_NAMESPACE,
12
+ (): DbPrismaConfigValues => {
13
+ const env = parseDbPrismaEnv(process.env);
14
+ return {
15
+ databaseUrl: env.DATABASE_URL,
16
+ };
17
+ },
18
+ );
@@ -0,0 +1,12 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { DB_PRISMA_CONFIG_NAMESPACE } from './db-prisma-config.loader';
4
+
5
+ @Injectable()
6
+ export class DbPrismaConfigService {
7
+ constructor(private readonly configService: ConfigService) {}
8
+
9
+ get databaseUrl(): string {
10
+ return this.configService.getOrThrow<string>(`${DB_PRISMA_CONFIG_NAMESPACE}.databaseUrl`);
11
+ }
12
+ }
@@ -0,0 +1,17 @@
1
+ import { z } from 'zod';
2
+
3
+ export const dbPrismaEnvSchema = z
4
+ .object({
5
+ DATABASE_URL: z
6
+ .string()
7
+ .trim()
8
+ .min(1)
9
+ .default('postgresql://postgres:postgres@localhost:5432/app?schema=public'),
10
+ })
11
+ .passthrough();
12
+
13
+ export type DbPrismaEnv = z.infer<typeof dbPrismaEnvSchema>;
14
+
15
+ export function parseDbPrismaEnv(input: Record<string, unknown>): DbPrismaEnv {
16
+ return dbPrismaEnvSchema.parse(input);
17
+ }
@@ -0,0 +1,13 @@
1
+ import { Global, Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { dbPrismaConfig } from './db-prisma-config.loader';
4
+ import { DbPrismaConfigService } from './db-prisma-config.service';
5
+ import { PrismaService } from './prisma.service';
6
+
7
+ @Global()
8
+ @Module({
9
+ imports: [ConfigModule.forFeature(dbPrismaConfig)],
10
+ providers: [DbPrismaConfigService, PrismaService],
11
+ exports: [DbPrismaConfigService, PrismaService],
12
+ })
13
+ export class DbPrismaModule {}
@@ -0,0 +1,5 @@
1
+ export * from './db-prisma-env.schema';
2
+ export * from './db-prisma-config.loader';
3
+ export * from './db-prisma-config.service';
4
+ export * from './db-prisma.module';
5
+ export * from './prisma.service';
@@ -1,27 +1,24 @@
1
- import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
2
- import { PrismaClient } from '@prisma/client';
3
-
4
- @Injectable()
5
- export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
6
- constructor() {
7
- const databaseUrl =
8
- process.env.DATABASE_URL ??
9
- 'postgresql://postgres:postgres@localhost:5432/app?schema=public';
10
-
11
- super({
12
- datasources: {
13
- db: {
14
- url: databaseUrl,
15
- },
16
- },
17
- });
18
- }
19
-
20
- async onModuleInit(): Promise<void> {
21
- await this.$connect();
22
- }
23
-
24
- async onModuleDestroy(): Promise<void> {
25
- await this.$disconnect();
26
- }
27
- }
1
+ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
2
+ import { PrismaClient } from '@prisma/client';
3
+ import { DbPrismaConfigService } from './db-prisma-config.service';
4
+
5
+ @Injectable()
6
+ export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
7
+ constructor(configService: DbPrismaConfigService) {
8
+ super({
9
+ datasources: {
10
+ db: {
11
+ url: configService.databaseUrl,
12
+ },
13
+ },
14
+ });
15
+ }
16
+
17
+ async onModuleInit(): Promise<void> {
18
+ await this.$connect();
19
+ }
20
+
21
+ async onModuleDestroy(): Promise<void> {
22
+ await this.$disconnect();
23
+ }
24
+ }
@@ -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
+ }
@@ -6,6 +6,6 @@
6
6
 
7
7
  ## Config Strategy
8
8
 
9
- - `@forgeon/core` owns base runtime config (port, API prefix, node env).
9
+ - `@forgeon/core` owns base runtime config, global error envelope/filter, and validation pipe defaults.
10
10
  - Core config is validated with Zod and exposed through typed accessors.
11
11
  - Add-modules own and validate only their module-specific env keys.
@@ -0,0 +1,11 @@
1
+ ## Error Handling Strategy
2
+
3
+ - `@forgeon/core` owns the global HTTP error envelope.
4
+ - API apps import `CoreErrorsModule` and register `CoreExceptionFilter` as a global filter.
5
+ - Envelope fields:
6
+ - `error.code`
7
+ - `error.message`
8
+ - `error.status`
9
+ - `error.details` (optional, mainly validation context)
10
+ - `error.requestId` (optional, derived from `x-request-id`)
11
+ - `error.timestamp`
@@ -1,7 +1,9 @@
1
- ## Default DB Stack
2
-
3
- Current default stack is `{{DB_LABEL}}`.
4
-
5
- - Prisma schema and migrations live in `apps/api/prisma`
6
- - DB access is encapsulated via `PrismaModule` (`apps/api/src/prisma`)
7
- - Additional DB presets are intentionally out of scope for the current milestone.
1
+ ## Default DB Stack
2
+
3
+ Current default stack is `{{DB_LABEL}}`.
4
+
5
+ - Prisma schema and migrations live in `apps/api/prisma`
6
+ - DB access is encapsulated via `DbPrismaModule` in `@forgeon/db-prisma`
7
+ - `db-prisma` is currently default-applied during scaffold generation.
8
+ - Future direction: this DB layer may be extracted into an explicit add-module/preset and optionally exposed via CLI flag(s).
9
+ - Additional DB presets are intentionally out of scope for the current milestone.
@@ -1,5 +1,6 @@
1
- ## Scope Freeze (Current)
2
-
3
- - Frontend preset selection is disabled (React is fixed).
4
- - DB preset selection is disabled (Prisma/Postgres is fixed).
5
- - Docker is always generated; runtime proxy is selectable (`caddy|nginx|none`).
1
+ ## Scope Freeze (Current)
2
+
3
+ - Frontend preset selection is disabled (React is fixed).
4
+ - DB preset selection is disabled (Prisma/Postgres is fixed).
5
+ - Docker is always generated; runtime proxy is selectable (`caddy|nginx|none`).
6
+ - DB preset flags may return in a future milestone after `db-prisma` is separated into an explicit preset/module flow.
@@ -2,4 +2,5 @@
2
2
 
3
3
  - `apps/api` - NestJS backend
4
4
  - `apps/web` - React frontend (`{{FRONTEND_LABEL}}`, fixed)
5
- - `packages/core` - shared backend core package (`core-config` and future core submodules)
5
+ - `packages/core` - shared backend core package (`core-config`, `core-errors`, `core-validation`)
6
+ - `packages/db-prisma` - default DB module (`DbPrismaModule`, Prisma service + config)
@@ -0,0 +1,7 @@
1
+ ### Error Handling
2
+
3
+ `core-errors` is enabled by default.
4
+
5
+ - `CoreErrorsModule` is imported in `apps/api/src/app.module.ts`.
6
+ - `CoreExceptionFilter` is registered globally in `apps/api/src/main.ts`.
7
+ - Controllers and services should throw standard Nest exceptions; envelope formatting is handled centrally.
@@ -0,0 +1,27 @@
1
+ ## Error Handling (`core-errors`)
2
+
3
+ `@forgeon/core` includes a default global exception filter (`CoreExceptionFilter`).
4
+
5
+ Wiring:
6
+ - module import: `apps/api/src/app.module.ts` (`CoreErrorsModule`)
7
+ - global registration: `apps/api/src/main.ts` (`app.useGlobalFilters(app.get(CoreExceptionFilter))`)
8
+
9
+ Usage:
10
+ - throw standard Nest exceptions in services/controllers:
11
+ - `throw new ConflictException('Email already exists')`
12
+ - `throw new NotFoundException('Resource not found')`
13
+
14
+ Response envelope:
15
+
16
+ ```json
17
+ {
18
+ "error": {
19
+ "code": "conflict",
20
+ "message": "Email already exists",
21
+ "status": 409,
22
+ "details": [],
23
+ "requestId": "optional",
24
+ "timestamp": "2026-02-25T12:00:00.000Z"
25
+ }
26
+ }
27
+ ```
@@ -1,9 +0,0 @@
1
- import { Global, Module } from '@nestjs/common';
2
- import { PrismaService } from './prisma.service';
3
-
4
- @Global()
5
- @Module({
6
- providers: [PrismaService],
7
- exports: [PrismaService],
8
- })
9
- export class PrismaModule {}