@nest-arch/tui 0.4.5 → 0.5.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/dist/cli.js +1 -1
- package/dist/constants-DX5wiqrq.js +1 -0
- package/dist/index.js +158 -145
- package/dist/templates/addons/husky/noop-hook.hbs +2 -0
- package/dist/templates/addons/husky/pre-commit.hbs +1 -1
- package/dist/templates/base/.env.example.hbs +16 -2
- package/dist/templates/base/.env.hbs +16 -2
- package/dist/templates/base/README.md.hbs +23 -2
- package/dist/templates/base/pnpm-workspace.yaml.hbs +9 -4
- package/dist/templates/base/prisma.config.ts.hbs +6 -0
- package/dist/templates/base/src/app.module.ts.hbs +6 -22
- package/dist/templates/base/src/database/adapters/interfaces/db-adapter.interface.ts.hbs +1 -1
- package/dist/templates/base/src/database/adapters/mongo/raw-mongo.adapter.ts.hbs +4 -2
- package/dist/templates/base/src/database/db.module.ts.hbs +30 -3
- package/dist/templates/base/src/database/db.service.ts.hbs +11 -8
- package/dist/templates/base/src/database/entities/todo.entity.ts.hbs +2 -2
- package/dist/templates/base/src/database/index.ts.hbs +2 -0
- package/dist/templates/base/src/database/orm/drizzle/schema.ts.hbs +48 -12
- package/dist/templates/base/src/database/orm/typeorm/data-source.ts.hbs +26 -10
- package/dist/templates/base/src/libs/middlewares/graphql-throttler.guard.ts.hbs +22 -0
- package/dist/templates/base/src/libs/trpc/inner.context.ts.hbs +2 -2
- package/dist/templates/base/src/main.ts.hbs +7 -6
- package/dist/templates/base/test/app.e2e-spec.ts.hbs +374 -198
- package/dist/templates/base/test/bootstrap.e2e-spec.ts.hbs +2 -2
- package/dist/templates/extras/docker/Dockerfile.hbs +8 -1
- package/dist/templates/extras/health/src/health/health.controller.ts.hbs +7 -1
- package/dist/templates/extras/swagger/create-docs.ts.hbs +6 -1
- package/dist/templates/feature/graphql/src/graphql/models/todo.model.ts.hbs +1 -1
- package/dist/templates/feature/graphql/src/graphql/resolvers/todo.resolver.ts.hbs +1 -1
- package/dist/templates/feature/rest/todo-example/src/api/todo/todo.controller.ts.hbs +80 -7
- package/dist/templates/feature/rest/todo-example/src/api/todo/todo.service.ts.hbs +181 -25
- package/dist/templates/monorepo/apps/api-gateway/src/main.ts.hbs +10 -9
- package/dist/templates/monorepo/root/README.md.hbs +8 -13
- package/dist/templates/single-project/architecture/nest-api/.env.hbs +15 -2
- package/dist/templates/single-project/architecture/nest-api/pnpm-workspace.yaml.hbs +6 -0
- package/dist/templates/single-project/architecture/nest-api/src/main.ts.hbs +10 -9
- package/dist/templates/single-project/architecture/nest-gateway/src/main.ts.hbs +10 -9
- package/dist/templates/single-project/architecture/nest-microservice/.env.hbs +6 -0
- package/dist/templates/single-project/architecture/nest-microservice/src/app.controller.ts.hbs +2 -2
- package/dist/templates/single-project/architecture/nest-microservice/src/app.module.ts.hbs +28 -1
- package/dist/templates/single-project/architecture/nest-microservice/src/http-throttler.guard.ts.hbs +13 -0
- package/dist/templates/single-project/architecture/nest-microservice/src/main.ts.hbs +10 -9
- package/dist/templates/single-project/architecture/nest-microservice/test/microservice.e2e-spec.ts.hbs +35 -0
- package/dist/templates/single-project/shared/config/env.ts.hbs +9 -0
- package/package.json +5 -5
- package/dist/constants-BbC7KTI7.js +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
set -e
|
|
3
3
|
|
|
4
|
-
{{#if isPnpm}}pnpm test{{else if isYarn}}yarn test{{else if isBun}}bun run test{{else}}npm test{{/if}}
|
|
4
|
+
{{#if isPnpm}}pnpm test --passWithNoTests{{else if isYarn}}yarn test --passWithNoTests{{else if isBun}}bun run test --passWithNoTests{{else}}npm test -- --passWithNoTests{{/if}}
|
|
5
5
|
|
|
6
6
|
{{#if hasFormatter}}
|
|
7
7
|
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Server
|
|
2
2
|
PORT=3000
|
|
3
3
|
NODE_ENV=development
|
|
4
|
-
CORS_ORIGIN=http://localhost:
|
|
4
|
+
CORS_ORIGIN=http://localhost:3000
|
|
5
5
|
# Comma-separated trusted reverse proxy IP addresses or CIDRs
|
|
6
6
|
TRUSTED_PROXY_IPS={{#each trustedProxyIps}}{{#unless @first}},{{/unless}}{{this}}{{/each}}
|
|
7
7
|
|
|
@@ -17,6 +17,15 @@ MYSQL_URL=mysql://root:mysql@localhost:3306/{{projectNameKebab}}_db
|
|
|
17
17
|
{{#if hasSqlite}}
|
|
18
18
|
SQLITE_PATH=./src/database/dev.db
|
|
19
19
|
{{/if}}
|
|
20
|
+
{{#if hasSqlserver}}
|
|
21
|
+
SQLSERVER_HOST=localhost
|
|
22
|
+
SQLSERVER_PORT=1433
|
|
23
|
+
SQLSERVER_DATABASE={{projectNameKebab}}_db
|
|
24
|
+
SQLSERVER_USER=sa
|
|
25
|
+
SQLSERVER_PASSWORD=DevOnly_Strong_Password_2026!X9
|
|
26
|
+
SQLSERVER_ENCRYPT=true
|
|
27
|
+
SQLSERVER_TRUST_SERVER_CERTIFICATE=true
|
|
28
|
+
{{/if}}
|
|
20
29
|
{{#if hasMongodb}}
|
|
21
30
|
MONGODB_URL=mongodb://localhost:27017/{{projectNameKebab}}_db
|
|
22
31
|
{{/if}}
|
|
@@ -26,7 +35,12 @@ MONGODB_URL=mongodb://localhost:27017/{{projectNameKebab}}_db
|
|
|
26
35
|
GRAPHQL_PLAYGROUND=true
|
|
27
36
|
|
|
28
37
|
{{/if}}
|
|
29
|
-
{{#if (eq auth "passport")}}
|
|
38
|
+
{{#if (and (eq auth "passport") (not hasDatabase))}}
|
|
39
|
+
# External identity provider (RS256 JWKS)
|
|
40
|
+
OIDC_JWKS_URI=https://identity.example.com/.well-known/jwks.json
|
|
41
|
+
OIDC_ISSUER=https://identity.example.com/
|
|
42
|
+
OIDC_AUDIENCE=generated-resource-server
|
|
43
|
+
{{else if (eq auth "passport")}}
|
|
30
44
|
# JWT
|
|
31
45
|
JWT_ACCESS_SECRET=change-me-to-a-random-secret-at-least-32-characters-long
|
|
32
46
|
{{#if hasDatabase}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
CORS_ORIGIN=http://localhost:
|
|
1
|
+
CORS_ORIGIN=http://localhost:3000
|
|
2
2
|
PORT=3000
|
|
3
3
|
TRUSTED_PROXY_IPS={{#each trustedProxyIps}}{{#unless @first}},{{/unless}}{{this}}{{/each}}
|
|
4
4
|
{{#if hasGraphql}}
|
|
@@ -14,10 +14,24 @@ MYSQL_URL=mysql://root:mysql@localhost:3306/{{projectNameKebab}}_db
|
|
|
14
14
|
{{#if hasSqlite}}
|
|
15
15
|
SQLITE_PATH=./src/database/dev.db
|
|
16
16
|
{{/if}}
|
|
17
|
+
{{#if hasSqlserver}}
|
|
18
|
+
SQLSERVER_HOST=localhost
|
|
19
|
+
SQLSERVER_PORT=1433
|
|
20
|
+
SQLSERVER_DATABASE={{projectNameKebab}}_db
|
|
21
|
+
SQLSERVER_USER=sa
|
|
22
|
+
SQLSERVER_PASSWORD=DevOnly_Strong_Password_2026!X9
|
|
23
|
+
SQLSERVER_ENCRYPT=true
|
|
24
|
+
SQLSERVER_TRUST_SERVER_CERTIFICATE=true
|
|
25
|
+
{{/if}}
|
|
17
26
|
{{#if hasMongodb}}
|
|
18
27
|
MONGODB_URL=mongodb://localhost:27017/{{projectNameKebab}}_db
|
|
19
28
|
{{/if}}
|
|
20
|
-
{{#if (eq auth "passport")}}
|
|
29
|
+
{{#if (and (eq auth "passport") (not hasDatabase))}}
|
|
30
|
+
# External identity provider (RS256 JWKS)
|
|
31
|
+
OIDC_JWKS_URI=https://identity.example.com/.well-known/jwks.json
|
|
32
|
+
OIDC_ISSUER=https://identity.example.com/
|
|
33
|
+
OIDC_AUDIENCE=generated-resource-server
|
|
34
|
+
{{else if (eq auth "passport")}}
|
|
21
35
|
JWT_ACCESS_SECRET=change-me-to-a-random-secret-at-least-32-characters-long
|
|
22
36
|
{{#if hasDatabase}}
|
|
23
37
|
JWT_REFRESH_SECRET=change-me-to-a-different-random-secret-at-least-32-characters
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
## Setup
|
|
6
6
|
|
|
7
|
+
{{#if setupInstructions}}
|
|
8
|
+
{{{setupInstructions}}}
|
|
9
|
+
{{else}}
|
|
7
10
|
```bash
|
|
8
11
|
# Install dependencies
|
|
9
12
|
{{#if isPnpm}}pnpm install{{else if isYarn}}yarn install{{else if isBun}}bun install{{else}}npm install{{/if}}
|
|
10
13
|
```
|
|
14
|
+
{{/if}}
|
|
11
15
|
|
|
12
16
|
## Running the App
|
|
13
17
|
|
|
@@ -54,6 +58,10 @@ This project uses **Prisma** as the ORM. Available commands:
|
|
|
54
58
|
{{#if isPnpm}}pnpm prisma:migrate{{else if isYarn}}yarn prisma:migrate{{else if isBun}}bun run prisma:migrate{{else}}npm run prisma:migrate{{/if}}
|
|
55
59
|
{{#if isPnpm}}pnpm prisma:migrate:deploy{{else if isYarn}}yarn prisma:migrate:deploy{{else if isBun}}bun run prisma:migrate:deploy{{else}}npm run prisma:migrate:deploy{{/if}}
|
|
56
60
|
```
|
|
61
|
+
{{#if hasSqlserver}}
|
|
62
|
+
|
|
63
|
+
Set `SQLSERVER_HOST`, `SQLSERVER_PORT`, `SQLSERVER_DATABASE`, `SQLSERVER_USER`, and `SQLSERVER_PASSWORD` in `.env` before running Prisma commands. The SQL Server database must already exist. The generated password is a development-only example that satisfies SQL Server's password policy; replace it with a unique secret before sharing the project or deploying it. For local development, set `SQLSERVER_TRUST_SERVER_CERTIFICATE=true` when using a self-signed certificate; keep certificate validation enabled for production.
|
|
64
|
+
{{/if}}
|
|
57
65
|
{{/if}}
|
|
58
66
|
|
|
59
67
|
{{#if useDrizzle}}
|
|
@@ -94,9 +102,14 @@ This project uses **Better Auth** with database-backed sessions. Set `BETTER_AUT
|
|
|
94
102
|
|
|
95
103
|
Run `auth:generate` with your package manager. The script uses the installed CLI version.
|
|
96
104
|
{{#if usePrisma}}
|
|
97
|
-
|
|
105
|
+
{{#if hasMongodb}}
|
|
106
|
+
`auth:generate` normalizes MongoDB IDs and writes auth models to `src/database/orm/prisma/auth.prisma`, using `auth-schema.prisma` as intermediate CLI output. Then run `prisma:push` and `prisma:generate`, as shown in Setup.
|
|
107
|
+
{{else}}
|
|
108
|
+
`auth:generate` updates auth models in `src/database/orm/prisma/schema.prisma`. For local development, run `prisma:push` and then `prisma:generate`, as shown in Setup. For a workflow with migration history, use `prisma:migrate --name auth` instead of pushing the schema and then regenerate the client. For deployments, apply the committed migrations with `prisma:migrate:deploy`.
|
|
109
|
+
{{/if}}
|
|
110
|
+
Inspect the schema diff after changing plugins.
|
|
98
111
|
{{else if useDrizzle}}
|
|
99
|
-
|
|
112
|
+
For local development, run `db:push`, as shown in Setup. For a workflow with migration history, use `db:generate` and `db:migrate` instead. Auth owns `src/database/orm/drizzle/auth-schema.ts`; application tables remain in `schema.ts`. Commit generated migration files. SQLite requires no Docker service.
|
|
100
113
|
{{else}}
|
|
101
114
|
Then run `auth:migrate` and `build`.
|
|
102
115
|
{{/if}}
|
|
@@ -229,3 +242,11 @@ Send queries to `POST /graphql`. `{ hello }` is always available. With the Todo
|
|
|
229
242
|
|
|
230
243
|
Set `MONGODB_URL` including the database name. The native client connects and pings MongoDB during startup and closes with the Nest application. No Prisma migration commands are required. Todo identifiers are serialized ObjectId strings; malformed IDs return 400 and missing mutation targets return 404. Listing returns at most 100 items in identifier order. This baseline performs single-document writes and does not promise multi-document transactions.
|
|
231
244
|
{{/if}}
|
|
245
|
+
{{#if docker}}
|
|
246
|
+
## Docker Compose
|
|
247
|
+
|
|
248
|
+
Start the application and its database with `docker compose up --build`. Compose publishes the API on `127.0.0.1:3000` by default (Fastify uses `3001`). If that host port is already in use or reserved, set `APP_PORT` to a free host port before starting; the container's internal port stays unchanged. In PowerShell, for example: `$env:APP_PORT = "3100"; docker compose up --build`.
|
|
249
|
+
{{#if hasSqlserver}}
|
|
250
|
+
Compose also publishes SQL Server on `127.0.0.1:1433` so Prisma commands run on the host can connect. The API container uses the internal `sqlserver:1433` address. If host port 1433 is occupied, change `SQLSERVER_PORT` in `.env`; keep the API container's internal database port at 1433.
|
|
251
|
+
{{/if}}
|
|
252
|
+
{{/if}}
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
allowBuilds:
|
|
2
|
-
{{#if useDrizzle}}
|
|
2
|
+
{{#if (or useDrizzle useTypescript7)}}
|
|
3
3
|
esbuild: true
|
|
4
4
|
{{/if}}
|
|
5
5
|
protobufjs: true
|
|
6
6
|
"@nestjs/core": true
|
|
7
|
-
{{#if useTypescript7}}
|
|
8
|
-
esbuild: true
|
|
9
|
-
{{/if}}
|
|
10
7
|
{{#if swagger}}
|
|
11
8
|
"@scarf/scarf": true
|
|
12
9
|
{{/if}}
|
|
@@ -20,7 +17,9 @@ allowBuilds:
|
|
|
20
17
|
"@prisma/client": true
|
|
21
18
|
{{/if}}
|
|
22
19
|
{{#if isPrisma8}}
|
|
20
|
+
{{#unless (or useDrizzle useTypescript7)}}
|
|
23
21
|
esbuild: true
|
|
22
|
+
{{/unless}}
|
|
24
23
|
msgpackr-extract: true
|
|
25
24
|
workerd: true
|
|
26
25
|
{{/if}}
|
|
@@ -31,3 +30,9 @@ allowBuilds:
|
|
|
31
30
|
{{#if hasSqlite}}
|
|
32
31
|
better-sqlite3: true
|
|
33
32
|
{{/if}}
|
|
33
|
+
{{#if hasTrpc}}
|
|
34
|
+
peerDependencyRules:
|
|
35
|
+
allowedVersions:
|
|
36
|
+
"nestjs-trpc>@nestjs/common": "^12.0.0"
|
|
37
|
+
"nestjs-trpc>@nestjs/core": "^12.0.0"
|
|
38
|
+
{{/if}}
|
|
@@ -27,10 +27,16 @@ import { defineConfig } from "prisma/config";
|
|
|
27
27
|
|
|
28
28
|
export default defineConfig({
|
|
29
29
|
schema: "src/database/orm/prisma{{#unless isBetterAuth}}/schema.prisma{{/unless}}",
|
|
30
|
+
{{#unless isMonorepo}}
|
|
31
|
+
migrations: {
|
|
32
|
+
path: "src/database/orm/prisma/migrations",
|
|
33
|
+
},
|
|
34
|
+
{{/unless}}
|
|
30
35
|
datasource: {
|
|
31
36
|
{{#if hasPostgres}}url: env.POSTGRES_URL,{{/if}}
|
|
32
37
|
{{#if hasMysql}}url: env.MYSQL_URL,{{/if}}
|
|
33
38
|
{{#if hasSqlite}}url: env.SQLITE_PATH,{{/if}}
|
|
39
|
+
{{#if hasSqlserver}}url: `sqlserver://${env.SQLSERVER_HOST}:${env.SQLSERVER_PORT};database=${env.SQLSERVER_DATABASE};user={${env.SQLSERVER_USER.replaceAll("}", "}}")}};password={${env.SQLSERVER_PASSWORD.replaceAll("}", "}}")}};encrypt=${env.SQLSERVER_ENCRYPT};trustServerCertificate=${env.SQLSERVER_TRUST_SERVER_CERTIFICATE}`,{{/if}}
|
|
34
40
|
{{#if hasMongodb}}url: env.MONGODB_URL,{{/if}}
|
|
35
41
|
},
|
|
36
42
|
});
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import { Module
|
|
2
|
-
{{#if (and hasGraphql hasRateLimiting)}}
|
|
3
|
-
import { GqlExecutionContext } from "@nestjs/graphql";
|
|
4
|
-
{{/if}}
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
5
2
|
{{#if (and isPassportAuth hasDatabase)}}
|
|
6
3
|
import { PassportModule } from "@nestjs/passport";
|
|
7
4
|
{{/if}}
|
|
@@ -50,30 +47,16 @@ import { HealthModule } from "./health/health.module.js";
|
|
|
50
47
|
{{/if}}
|
|
51
48
|
{{#if hasRateLimiting}}
|
|
52
49
|
import { APP_GUARD } from "@nestjs/core";
|
|
53
|
-
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
|
50
|
+
import { {{#unless hasGraphql}}ThrottlerGuard, {{/unless}}ThrottlerModule } from "@nestjs/throttler";
|
|
51
|
+
{{/if}}
|
|
52
|
+
{{#if (and hasGraphql hasRateLimiting)}}
|
|
53
|
+
import { GraphqlThrottlerGuard } from "./libs/middlewares/graphql-throttler.guard.js";
|
|
54
54
|
{{/if}}
|
|
55
55
|
{{#if (and isPassportAuth hasDatabase hasGraphql (not hasRateLimiting))}}
|
|
56
56
|
import { APP_GUARD } from "@nestjs/core";
|
|
57
57
|
{{/if}}
|
|
58
58
|
import { AppService } from "./app.service.js";
|
|
59
59
|
|
|
60
|
-
{{#if (and hasGraphql hasRateLimiting)}}
|
|
61
|
-
@Injectable()
|
|
62
|
-
class GraphqlThrottlerGuard extends ThrottlerGuard {
|
|
63
|
-
protected getRequestResponse(context: ExecutionContext) {
|
|
64
|
-
if (context.getType<string>() !== "graphql") return super.getRequestResponse(context);
|
|
65
|
-
const gqlContext = GqlExecutionContext.create(context).getContext();
|
|
66
|
-
return { req: gqlContext.req, res: { header: () => undefined } };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
protected getTracker(request: Record<string, unknown>): Promise<string> {
|
|
70
|
-
const socket = request.socket as { remoteAddress?: string } | undefined;
|
|
71
|
-
const ip = request.ip;
|
|
72
|
-
return Promise.resolve(typeof ip === "string" ? ip : socket?.remoteAddress ?? "unknown");
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
{{/if}}
|
|
76
|
-
|
|
77
60
|
@Module({
|
|
78
61
|
{{#if hasRest}}
|
|
79
62
|
controllers: [
|
|
@@ -86,6 +69,7 @@ class GraphqlThrottlerGuard extends ThrottlerGuard {
|
|
|
86
69
|
{{#if hasPostgres}}postgresUrl: env.POSTGRES_URL,{{/if}}
|
|
87
70
|
{{#if hasMysql}}mysqlUrl: env.MYSQL_URL,{{/if}}
|
|
88
71
|
{{#if hasSqlite}}sqlitePath: env.SQLITE_PATH,{{/if}}
|
|
72
|
+
{{#if hasSqlserver}}sqlserver: { server: env.SQLSERVER_HOST, port: env.SQLSERVER_PORT, database: env.SQLSERVER_DATABASE, user: env.SQLSERVER_USER, password: env.SQLSERVER_PASSWORD, options: { encrypt: env.SQLSERVER_ENCRYPT, trustServerCertificate: env.SQLSERVER_TRUST_SERVER_CERTIFICATE } },{{/if}}
|
|
89
73
|
{{#if hasMongodb}}mongodbUrl: env.MONGODB_URL,{{/if}}
|
|
90
74
|
}),
|
|
91
75
|
{{/if}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
1
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
2
2
|
export interface ISqlAdapter<TClient = unknown> {
|
|
3
3
|
query: <T = unknown>(sql: string, params?: unknown[]) => Promise<T[]>;
|
|
4
4
|
execute: (sql: string, params?: unknown[]) => Promise<unknown>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { Injectable, Inject
|
|
2
|
-
import
|
|
1
|
+
import { Injectable, Inject } from "@nestjs/common";
|
|
2
|
+
import type { OnModuleDestroy, OnModuleInit } from "@nestjs/common";
|
|
3
|
+
import { MongoClient } from "mongodb";
|
|
4
|
+
import type { Collection, Db, Document } from "mongodb";
|
|
3
5
|
import type { IMongoAdapter } from "../interfaces/db-adapter.interface.js";
|
|
4
6
|
|
|
5
7
|
@Injectable()
|
|
@@ -27,7 +27,7 @@ import { PrismaClient } from '@prisma/client';
|
|
|
27
27
|
import { PrismaClient } from '../database/orm/prisma/generated/client.js';
|
|
28
28
|
{{/if}}
|
|
29
29
|
{{/if}}
|
|
30
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
30
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
31
31
|
import { PrismaSqlAdapter } from './adapters/sql/prisma-sql.adapter.js';
|
|
32
32
|
{{/if}}
|
|
33
33
|
{{#if hasPostgres}}
|
|
@@ -38,6 +38,9 @@ import { PrismaMariaDb } from '@prisma/adapter-mariadb';
|
|
|
38
38
|
{{/if}}
|
|
39
39
|
{{#if hasSqlite}}
|
|
40
40
|
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
|
|
41
|
+
{{/if}}
|
|
42
|
+
{{#if hasSqlserver}}
|
|
43
|
+
import { PrismaMssql } from '@prisma/adapter-mssql';
|
|
41
44
|
{{/if}}
|
|
42
45
|
{{#if hasMongodb}}
|
|
43
46
|
import { PrismaMongoAdapter } from './adapters/mongo/prisma-mongo.adapter.js';
|
|
@@ -63,6 +66,10 @@ import mysql from 'mysql2/promise';
|
|
|
63
66
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
64
67
|
import Database from 'better-sqlite3';
|
|
65
68
|
{{/if}}
|
|
69
|
+
{{#if hasSqlserver}}
|
|
70
|
+
import { drizzle } from 'drizzle-orm/node-mssql';
|
|
71
|
+
import sql from 'mssql';
|
|
72
|
+
{{/if}}
|
|
66
73
|
{{/if}}
|
|
67
74
|
|
|
68
75
|
{{#if useTypeorm}}
|
|
@@ -99,6 +106,9 @@ export interface DbModuleOptions {
|
|
|
99
106
|
{{#if hasSqlite}}
|
|
100
107
|
sqlitePath?: string;
|
|
101
108
|
{{/if}}
|
|
109
|
+
{{#if hasSqlserver}}
|
|
110
|
+
sqlserver?: { server: string; port: number; database: string; user: string; password: string; options: { encrypt: boolean; trustServerCertificate: boolean } };
|
|
111
|
+
{{/if}}
|
|
102
112
|
{{#if hasMongodb}}
|
|
103
113
|
mongodbUrl?: string;
|
|
104
114
|
{{/if}}
|
|
@@ -119,7 +129,7 @@ export class DbModule {
|
|
|
119
129
|
const providers = initialProviders;
|
|
120
130
|
|
|
121
131
|
{{#if usePrisma}}
|
|
122
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
132
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
123
133
|
{{#if hasPostgres}}
|
|
124
134
|
const sqlAdapter = new PrismaPg({
|
|
125
135
|
connectionString: options.postgresUrl,
|
|
@@ -137,6 +147,13 @@ export class DbModule {
|
|
|
137
147
|
const sqlAdapter = new PrismaBetterSqlite3({
|
|
138
148
|
url: options.sqlitePath || ':memory:',
|
|
139
149
|
});
|
|
150
|
+
const prismaSql = new PrismaClient({ adapter: sqlAdapter });
|
|
151
|
+
{{/if}}
|
|
152
|
+
{{#if hasSqlserver}}
|
|
153
|
+
if (!options.sqlserver) {
|
|
154
|
+
throw new Error("SQL Server connection settings are required to initialize Prisma");
|
|
155
|
+
}
|
|
156
|
+
const sqlAdapter = new PrismaMssql(options.sqlserver);
|
|
140
157
|
const prismaSql = new PrismaClient({ adapter: sqlAdapter });
|
|
141
158
|
{{/if}}
|
|
142
159
|
providers.push({
|
|
@@ -171,13 +188,23 @@ export class DbModule {
|
|
|
171
188
|
{{#if useDrizzle}}
|
|
172
189
|
providers.push({
|
|
173
190
|
provide: 'DRIZZLE_CLIENT',
|
|
174
|
-
useFactory: () => {
|
|
191
|
+
useFactory: {{#if hasSqlserver}}async {{/if}}() => {
|
|
175
192
|
{{#if hasPostgres}}
|
|
176
193
|
const client = new Pool({ connectionString: options.postgresUrl });
|
|
177
194
|
return drizzle({ client, schema });
|
|
178
195
|
{{else if hasMysql}}
|
|
179
196
|
const client = mysql.createPool({ uri: options.mysqlUrl });
|
|
180
197
|
return drizzle(client, { schema, mode: "default" });
|
|
198
|
+
{{else if hasSqlserver}}
|
|
199
|
+
const client = await new sql.ConnectionPool({
|
|
200
|
+
server: options.sqlserver?.server ?? 'localhost',
|
|
201
|
+
port: options.sqlserver?.port ?? 1433,
|
|
202
|
+
database: options.sqlserver?.database ?? 'app_db',
|
|
203
|
+
user: options.sqlserver?.user ?? 'sa',
|
|
204
|
+
password: options.sqlserver?.password ?? 'DevOnly_Strong_Password_2026!X9',
|
|
205
|
+
options: options.sqlserver?.options ?? { encrypt: true, trustServerCertificate: true },
|
|
206
|
+
}).connect();
|
|
207
|
+
return drizzle({ client, schema });
|
|
181
208
|
{{else}}
|
|
182
209
|
const client = new Database(options.sqlitePath || ':memory:');
|
|
183
210
|
return drizzle({ client, schema });
|
|
@@ -18,7 +18,7 @@ export class DbService {
|
|
|
18
18
|
}
|
|
19
19
|
{{else}}
|
|
20
20
|
import { Injectable, Inject } from '@nestjs/common';
|
|
21
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
21
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
22
22
|
import type { ISqlAdapter } from './adapters/interfaces/db-adapter.interface.js';
|
|
23
23
|
{{/if}}
|
|
24
24
|
{{#if hasMongodb}}
|
|
@@ -49,6 +49,9 @@ import type { MySql2Database } from 'drizzle-orm/mysql2';
|
|
|
49
49
|
{{#if hasSqlite}}
|
|
50
50
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
|
51
51
|
{{/if}}
|
|
52
|
+
{{#if hasSqlserver}}
|
|
53
|
+
import type { NodeMsSqlDatabase } from 'drizzle-orm/node-mssql';
|
|
54
|
+
{{/if}}
|
|
52
55
|
{{#if isMonorepo}}
|
|
53
56
|
import type * as schema from './database/orm/drizzle/schema.js';
|
|
54
57
|
{{else}}
|
|
@@ -58,13 +61,13 @@ import type * as schema from './orm/drizzle/schema.js';
|
|
|
58
61
|
{{#if useTypeorm}}
|
|
59
62
|
import type { DataSource } from 'typeorm';
|
|
60
63
|
{{/if}}
|
|
61
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
64
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
62
65
|
{{#if usePrisma}}
|
|
63
66
|
type SqlClient = PrismaClient;
|
|
64
67
|
{{else if useTypeorm}}
|
|
65
68
|
type SqlClient = DataSource;
|
|
66
69
|
{{else if useDrizzle}}
|
|
67
|
-
type DrizzleClient = {{#if hasPostgres}}NodePgDatabase{{/if}}{{#if hasMysql}}MySql2Database{{/if}}{{#if hasSqlite}}BetterSQLite3Database{{/if}}<typeof schema
|
|
70
|
+
type DrizzleClient = {{#if hasPostgres}}NodePgDatabase{{/if}}{{#if hasMysql}}MySql2Database{{/if}}{{#if hasSqlite}}BetterSQLite3Database{{/if}}{{#if hasSqlserver}}NodeMsSqlDatabase<typeof schema>{{/if}}{{#unless hasSqlserver}}<typeof schema>{{/unless}};
|
|
68
71
|
type SqlClient = DrizzleClient;
|
|
69
72
|
{{else if hasPostgres}}
|
|
70
73
|
type SqlClient = import('pg').Pool;
|
|
@@ -91,7 +94,7 @@ import { db as prismaDb } from './prisma/db.js';
|
|
|
91
94
|
@Injectable()
|
|
92
95
|
export class DbService {
|
|
93
96
|
constructor(
|
|
94
|
-
|
|
97
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
95
98
|
@Inject('SQL_ADAPTER') readonly sql: ISqlAdapter<SqlClient>{{#if hasMongodb}},{{/if}}
|
|
96
99
|
{{/if}}
|
|
97
100
|
{{#if hasMongodb}}
|
|
@@ -101,7 +104,7 @@ export class DbService {
|
|
|
101
104
|
|
|
102
105
|
get raw() {
|
|
103
106
|
return {
|
|
104
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
107
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
105
108
|
sql: this.sql.getClient(),
|
|
106
109
|
{{/if}}
|
|
107
110
|
{{#if hasMongodb}}
|
|
@@ -114,7 +117,7 @@ export class DbService {
|
|
|
114
117
|
return prismaDb;
|
|
115
118
|
}
|
|
116
119
|
{{else if usePrisma}}
|
|
117
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
120
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
118
121
|
get prisma(): PrismaClient {
|
|
119
122
|
return this.sql.getClient();
|
|
120
123
|
}
|
|
@@ -125,14 +128,14 @@ export class DbService {
|
|
|
125
128
|
{{/if}}
|
|
126
129
|
{{/if}}
|
|
127
130
|
{{#if useDrizzle}}
|
|
128
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
131
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
129
132
|
get drizzle(): DrizzleClient {
|
|
130
133
|
return this.sql.getClient();
|
|
131
134
|
}
|
|
132
135
|
{{/if}}
|
|
133
136
|
{{/if}}
|
|
134
137
|
{{#if useTypeorm}}
|
|
135
|
-
{{#if (or hasPostgres hasMysql hasSqlite)}}
|
|
138
|
+
{{#if (or hasPostgres hasMysql hasSqlite hasSqlserver)}}
|
|
136
139
|
get typeorm(): DataSource {
|
|
137
140
|
return this.sql.getClient();
|
|
138
141
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
@Entity("todos")
|
|
14
14
|
export class Todo {
|
|
15
|
-
@PrimaryGeneratedColumn("uuid")
|
|
15
|
+
@PrimaryGeneratedColumn("{{#if hasSqlserver}}increment{{else}}uuid{{/if}}")
|
|
16
16
|
id: string;
|
|
17
17
|
|
|
18
18
|
@Column({ type: "varchar", length: 255, nullable: true })
|
|
@@ -21,7 +21,7 @@ export class Todo {
|
|
|
21
21
|
@Column({ type: "varchar", length: 255 })
|
|
22
22
|
title: string;
|
|
23
23
|
|
|
24
|
-
@Column({ default: false, name: "is_completed", type: "boolean" })
|
|
24
|
+
@Column({ default: false, name: "is_completed", type: "{{#if hasSqlserver}}bit{{else}}boolean{{/if}}" })
|
|
25
25
|
completed: boolean;
|
|
26
26
|
|
|
27
27
|
{{#if hasAuth}}
|
|
@@ -9,6 +9,7 @@ export type { PrismaClient } from '@prisma/client';
|
|
|
9
9
|
export type { Todo } from '@prisma/client';
|
|
10
10
|
{{/if}}
|
|
11
11
|
{{else}}
|
|
12
|
+
{{#unless isPrisma8}}
|
|
12
13
|
{{#if isMonorepo}}
|
|
13
14
|
export type { PrismaClient } from './database/orm/prisma/generated/client.js';
|
|
14
15
|
{{#if hasTodoExample}}
|
|
@@ -20,6 +21,7 @@ export type { PrismaClient } from './orm/prisma/generated/client.js';
|
|
|
20
21
|
export type { Todo } from './orm/prisma/generated/client.js';
|
|
21
22
|
{{/if}}
|
|
22
23
|
{{/if}}
|
|
24
|
+
{{/unless}}
|
|
23
25
|
{{/if}}
|
|
24
26
|
{{/if}}
|
|
25
27
|
export * from './adapters/interfaces/db-adapter.interface.js';
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
// oxlint-disable typescript/no-explicit-any
|
|
2
|
+
{{#if hasPostgres}}
|
|
3
|
+
import { {{#if hasTodoExample}}boolean, {{/if}}pgTable{{#if (or (not isPassportAuth) hasTodoExample)}}, serial{{/if}}, text{{#if (or isPassportAuth (eq auth "better-auth:stateless"))}}, timestamp{{/if}} } from 'drizzle-orm/pg-core';
|
|
4
|
+
{{/if}}
|
|
5
|
+
{{#if hasMysql}}
|
|
6
|
+
import { {{#if hasTodoExample}}boolean, {{/if}}mysqlTable{{#if (or (not isPassportAuth) hasTodoExample)}}, int{{/if}}, text{{#if (or isPassportAuth (eq auth "better-auth:stateless"))}}, timestamp, varchar{{/if}} } from 'drizzle-orm/mysql-core';
|
|
7
|
+
{{/if}}
|
|
8
|
+
{{#if hasSqlite}}
|
|
9
|
+
import { sqliteTable{{#if (or (not isPassportAuth) hasTodoExample)}}, integer{{/if}}, text } from 'drizzle-orm/sqlite-core';
|
|
10
|
+
{{/if}}
|
|
11
|
+
{{#if hasSqlserver}}
|
|
12
|
+
import { bit, int, mssqlTable, nvarchar } from 'drizzle-orm/mssql-core';
|
|
13
|
+
{{/if}}
|
|
2
14
|
{{#if (eq auth "better-auth")}}
|
|
3
15
|
// oxlint-disable-next-line oxc/no-barrel-file
|
|
4
16
|
export * from "./auth-schema.js";
|
|
5
17
|
{{/if}}
|
|
6
18
|
{{#if hasPostgres}}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
{{#unless (eq auth "better-auth")}}
|
|
19
|
+
{{#if (or isPassportAuth (eq auth "better-auth:stateless"))}}
|
|
10
20
|
export const users = pgTable('users', {
|
|
11
21
|
email: text('email').unique(),
|
|
12
22
|
{{#if isPassportAuth}}
|
|
@@ -23,7 +33,7 @@ export const users = pgTable('users', {
|
|
|
23
33
|
role: text('role').default('USER'),
|
|
24
34
|
{{/if}}
|
|
25
35
|
});
|
|
26
|
-
{{/
|
|
36
|
+
{{/if}}
|
|
27
37
|
{{#if hasTodoExample}}
|
|
28
38
|
export const todos = pgTable('todos', {
|
|
29
39
|
{{#if hasAuth}}
|
|
@@ -37,9 +47,7 @@ export const todos = pgTable('todos', {
|
|
|
37
47
|
{{/if}}
|
|
38
48
|
{{/if}}
|
|
39
49
|
{{#if hasMysql}}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{{#unless (eq auth "better-auth")}}
|
|
50
|
+
{{#if (or isPassportAuth (eq auth "better-auth:stateless"))}}
|
|
43
51
|
export const users = mysqlTable('users', {
|
|
44
52
|
email: varchar('email', { length: 320 }).unique(),
|
|
45
53
|
{{#if isPassportAuth}}
|
|
@@ -56,7 +64,7 @@ export const users = mysqlTable('users', {
|
|
|
56
64
|
role: text('role').default('USER'),
|
|
57
65
|
{{/if}}
|
|
58
66
|
});
|
|
59
|
-
{{/
|
|
67
|
+
{{/if}}
|
|
60
68
|
{{#if hasTodoExample}}
|
|
61
69
|
export const todos = mysqlTable('todos', {
|
|
62
70
|
{{#if hasAuth}}
|
|
@@ -70,9 +78,7 @@ export const todos = mysqlTable('todos', {
|
|
|
70
78
|
{{/if}}
|
|
71
79
|
{{/if}}
|
|
72
80
|
{{#if hasSqlite}}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
{{#unless (eq auth "better-auth")}}
|
|
81
|
+
{{#if (or isPassportAuth (eq auth "better-auth:stateless"))}}
|
|
76
82
|
export const users = sqliteTable('users', {
|
|
77
83
|
email: text('email').unique(),
|
|
78
84
|
{{#if isPassportAuth}}
|
|
@@ -88,7 +94,7 @@ export const users = sqliteTable('users', {
|
|
|
88
94
|
role: text('role').default('USER'),
|
|
89
95
|
{{/if}}
|
|
90
96
|
});
|
|
91
|
-
{{/
|
|
97
|
+
{{/if}}
|
|
92
98
|
{{#if hasTodoExample}}
|
|
93
99
|
export const todos = sqliteTable('todos', {
|
|
94
100
|
{{#if hasAuth}}
|
|
@@ -101,3 +107,33 @@ export const todos = sqliteTable('todos', {
|
|
|
101
107
|
});
|
|
102
108
|
{{/if}}
|
|
103
109
|
{{/if}}
|
|
110
|
+
{{#if hasSqlserver}}
|
|
111
|
+
{{#if (or isPassportAuth (eq auth "better-auth:stateless"))}}
|
|
112
|
+
export const users = mssqlTable('users', {
|
|
113
|
+
email: nvarchar('email', { length: 320 }).unique(),
|
|
114
|
+
{{#if isPassportAuth}}
|
|
115
|
+
id: nvarchar('id', { length: 255 }).primaryKey(),
|
|
116
|
+
{{else}}
|
|
117
|
+
id: int('id').identity().primaryKey(),
|
|
118
|
+
{{/if}}
|
|
119
|
+
name: nvarchar('name', { length: 255 }),
|
|
120
|
+
{{#if isPassportAuth}}
|
|
121
|
+
password: nvarchar('password', { length: 255 }).notNull(),
|
|
122
|
+
firstname: nvarchar('firstname', { length: 255 }),
|
|
123
|
+
lastname: nvarchar('lastname', { length: 255 }),
|
|
124
|
+
role: nvarchar('role', { length: 32 }).default('USER'),
|
|
125
|
+
{{/if}}
|
|
126
|
+
});
|
|
127
|
+
{{/if}}
|
|
128
|
+
{{#if hasTodoExample}}
|
|
129
|
+
export const todos = mssqlTable('todos', {
|
|
130
|
+
{{#if hasAuth}}
|
|
131
|
+
authorId: nvarchar('author_id', { length: 255 }),
|
|
132
|
+
{{/if}}
|
|
133
|
+
completed: bit('completed').notNull().default(false),
|
|
134
|
+
description: nvarchar('description', { length: 'max' }),
|
|
135
|
+
id: int('id').identity().primaryKey(),
|
|
136
|
+
title: nvarchar('title', { length: 255 }).notNull(),
|
|
137
|
+
});
|
|
138
|
+
{{/if}}
|
|
139
|
+
{{/if}}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// oxlint-disable typescript/no-non-null-assertion
|
|
2
2
|
import "reflect-metadata";
|
|
3
|
+
import "dotenv/config";
|
|
3
4
|
{{#if isMonorepo}}
|
|
4
5
|
import { env } from "@repo/env/api-gateway";
|
|
5
6
|
{{else}}
|
|
@@ -16,27 +17,42 @@ import { UserEntity } from "../../../modules/users/entities/user.entity.js";
|
|
|
16
17
|
import { AppEntity } from "../../entities/app.entity.js";
|
|
17
18
|
{{/if}}
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{{#unless hasSqlite}}
|
|
20
|
+
{{#unless (or hasSqlite hasSqlserver)}}
|
|
21
|
+
const databaseUrl = env.{{#if hasPostgres}}POSTGRES_URL{{/if}}{{#if hasMysql}}MYSQL_URL{{/if}};
|
|
23
22
|
const parsedUrl = new URL(databaseUrl);
|
|
24
23
|
{{/unless}}
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
export const AppDataSource = new DataSource({
|
|
28
27
|
{{#if hasSqlite}}
|
|
29
|
-
database:
|
|
28
|
+
database: env.SQLITE_PATH,
|
|
29
|
+
{{/if}}
|
|
30
|
+
{{#if hasSqlserver}}
|
|
31
|
+
database: env.SQLSERVER_DATABASE,
|
|
30
32
|
{{/if}}
|
|
31
33
|
// Register entities explicitly for consistent metadata in Node and test runners.
|
|
32
34
|
entities: [{{#if hasTodoExample}}Todo{{/if}}{{#if (and hasTodoExample (and useTypeorm isPassportAuth))}}, {{/if}}{{#if (and useTypeorm isPassportAuth)}}UserEntity{{/if}}{{#if (eq auth "better-auth:stateless")}}{{#if (or hasTodoExample (and useTypeorm isPassportAuth))}}, {{/if}}AppEntity{{/if}}],
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
{{#if hasSqlserver}}
|
|
36
|
+
host: env.SQLSERVER_HOST,
|
|
37
|
+
{{/if}}
|
|
38
|
+
migrations: [
|
|
39
|
+
"dist/database/orm/typeorm/migrations/*.js",
|
|
40
|
+
"src/database/orm/typeorm/migrations/*.js",
|
|
41
|
+
],
|
|
42
|
+
{{#if hasSqlserver}}
|
|
43
|
+
options: { encrypt: env.SQLSERVER_ENCRYPT, trustServerCertificate: env.SQLSERVER_TRUST_SERVER_CERTIFICATE },
|
|
44
|
+
password: env.SQLSERVER_PASSWORD,
|
|
45
|
+
port: env.SQLSERVER_PORT,
|
|
46
|
+
{{/if}}
|
|
47
|
+
synchronize: false,
|
|
48
|
+
type: "{{#if hasPostgres}}postgres{{/if}}{{#if hasMysql}}mysql{{/if}}{{#if hasSqlite}}better-sqlite3{{/if}}{{#if hasSqlserver}}mssql{{/if}}",
|
|
49
|
+
{{#if hasSqlserver}}
|
|
50
|
+
username: env.SQLSERVER_USER,
|
|
51
|
+
{{/if}}
|
|
52
|
+
{{#unless (or hasSqlite hasSqlserver)}}
|
|
37
53
|
url: databaseUrl,
|
|
38
54
|
{{/unless}}
|
|
39
|
-
{{#unless hasSqlite}}
|
|
55
|
+
{{#unless (or hasSqlite hasSqlserver)}}
|
|
40
56
|
...(parsedUrl.username ? { username: decodeURIComponent(parsedUrl.username) } : {}),
|
|
41
57
|
...(parsedUrl.password ? { password: decodeURIComponent(parsedUrl.password) } : {}),
|
|
42
58
|
{{/unless}}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Injectable, type ExecutionContext } from "@nestjs/common";
|
|
2
|
+
import { GqlExecutionContext } from "@nestjs/graphql";
|
|
3
|
+
import { ThrottlerGuard } from "@nestjs/throttler";
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class GraphqlThrottlerGuard extends ThrottlerGuard {
|
|
7
|
+
protected getRequestResponse(context: ExecutionContext) {
|
|
8
|
+
if (context.getType<string>() !== "graphql") {
|
|
9
|
+
return super.getRequestResponse(context);
|
|
10
|
+
}
|
|
11
|
+
const gqlContext = GqlExecutionContext.create(context).getContext();
|
|
12
|
+
return { req: gqlContext.req, res: { header: () => true } };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
protected getTracker(request: Record<string, unknown>): Promise<string> {
|
|
16
|
+
const socket = request.socket as { remoteAddress?: string } | undefined;
|
|
17
|
+
const ip = request.ip;
|
|
18
|
+
return Promise.resolve(
|
|
19
|
+
typeof ip === "string" ? ip : (socket?.remoteAddress ?? "unknown")
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|