feat: 添加 NestJS API 基础设施
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@drift/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/main.js",
|
||||
"test": "vitest run --config vitest.config.ts --no-file-parallelism",
|
||||
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts --no-file-parallelism",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@drift/contracts": "workspace:*",
|
||||
"@nestjs/common": "^11.1.6",
|
||||
"@nestjs/core": "^11.1.6",
|
||||
"@nestjs/platform-express": "^11.1.6",
|
||||
"@prisma/client": "6.19.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"helmet": "^8.1.0",
|
||||
"redis": "^5.8.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.6",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"supertest": "^7.1.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MiddlewareConsumer, Module, type NestModule } from "@nestjs/common";
|
||||
import { RequestIdMiddleware } from "./common/request-id.middleware.js";
|
||||
import { HealthModule } from "./health/health.module.js";
|
||||
|
||||
@Module({ imports: [HealthModule] })
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ErrorCode, type ErrorResponse } from "@drift/contracts";
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
type ExceptionFilter,
|
||||
} from "@nestjs/common";
|
||||
import type { Response } from "express";
|
||||
import { DomainException } from "./domain.exception.js";
|
||||
import type { RequestWithId } from "./request-id.middleware.js";
|
||||
|
||||
@Catch()
|
||||
export class DomainExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const http = host.switchToHttp();
|
||||
const request = http.getRequest<RequestWithId>();
|
||||
const response = http.getResponse<Response>();
|
||||
const normalized = this.normalize(exception);
|
||||
const body: ErrorResponse = {
|
||||
code: normalized.code,
|
||||
message: normalized.message,
|
||||
data: null,
|
||||
requestId: request.requestId,
|
||||
};
|
||||
response.status(normalized.status).json(body);
|
||||
}
|
||||
|
||||
private normalize(exception: unknown): {
|
||||
status: number;
|
||||
code: Exclude<ErrorCode, ErrorCode.OK>;
|
||||
message: string;
|
||||
} {
|
||||
if (exception instanceof DomainException) {
|
||||
return {
|
||||
status: exception.status,
|
||||
code: exception.code,
|
||||
message: exception.message,
|
||||
};
|
||||
}
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
if (status === 404)
|
||||
return { status, code: ErrorCode.NOT_FOUND, message: "Not Found" };
|
||||
if (status === 400)
|
||||
return {
|
||||
status,
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message: "Validation failed",
|
||||
};
|
||||
return {
|
||||
status,
|
||||
code: ErrorCode.INTERNAL_ERROR,
|
||||
message: "Request failed",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
code: ErrorCode.INTERNAL_ERROR,
|
||||
message: "Internal server error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ErrorCode } from "@drift/contracts";
|
||||
|
||||
export class DomainException extends Error {
|
||||
constructor(
|
||||
readonly code: Exclude<ErrorCode, ErrorCode.OK>,
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DomainException";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ok, type SuccessResponse } from "@drift/contracts";
|
||||
import {
|
||||
Injectable,
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
type NestInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import type { Observable } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
import type { RequestWithId } from "./request-id.middleware.js";
|
||||
|
||||
const isEnvelope = (value: unknown): value is SuccessResponse<unknown> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"code" in value &&
|
||||
"requestId" in value;
|
||||
|
||||
@Injectable()
|
||||
export class HttpResponseInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
T | SuccessResponse<T>
|
||||
> {
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<T>,
|
||||
): Observable<T | SuccessResponse<T>> {
|
||||
const request = context.switchToHttp().getRequest<RequestWithId>();
|
||||
return next
|
||||
.handle()
|
||||
.pipe(
|
||||
map((data) => (isEnvelope(data) ? data : ok(data, request.requestId))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, type NestMiddleware } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
export const REQUEST_ID_HEADER = "x-request-id";
|
||||
const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
||||
|
||||
export interface RequestWithId extends Request {
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(request: Request, response: Response, next: NextFunction): void {
|
||||
const supplied = request.header(REQUEST_ID_HEADER);
|
||||
const requestId =
|
||||
supplied && SAFE_REQUEST_ID.test(supplied) ? supplied : randomUUID();
|
||||
(request as RequestWithId).requestId = requestId;
|
||||
response.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import {
|
||||
Injectable,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
/** Lightweight database lifecycle wrapper; Nest hooks can call these methods later. */
|
||||
export class PrismaService extends PrismaClient {
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Inject } from "@nestjs/common";
|
||||
import { HealthService } from "./health.service.js";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(
|
||||
@Inject(HealthService) private readonly healthService: HealthService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
check(): Promise<{ status: "ok"; postgres: "up"; redis: "up" }> {
|
||||
return this.healthService.check();
|
||||
}
|
||||
|
||||
@Get("live")
|
||||
live(): { status: "ok" } {
|
||||
return { status: "ok" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import "reflect-metadata";
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access */
|
||||
import { Body, Controller, Get, HttpStatus, Post } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { IsString } from "class-validator";
|
||||
import request from "supertest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { INestApplication } from "@nestjs/common";
|
||||
import { ErrorCode } from "@drift/contracts";
|
||||
import { AppModule } from "../app.module";
|
||||
import { DomainException } from "../common/domain.exception";
|
||||
|
||||
import { configureApp } from "../main";
|
||||
|
||||
class ProbeDto {
|
||||
@IsString()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
@Controller("probe")
|
||||
class ProbeController {
|
||||
@Post()
|
||||
echo(@Body() dto: ProbeDto): ProbeDto {
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Get("domain-error")
|
||||
domainError(): never {
|
||||
throw new DomainException(
|
||||
ErrorCode.CONTENT_REJECTED,
|
||||
"content rejected",
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("internal-error")
|
||||
internalError(): never {
|
||||
throw new Error(`sensitive ${process.env.DATABASE_URL}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(
|
||||
"design:paramtypes",
|
||||
[ProbeDto],
|
||||
ProbeController.prototype,
|
||||
"echo",
|
||||
);
|
||||
|
||||
describe("API infrastructure", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
controllers: [ProbeController],
|
||||
}).compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
it("reports real dependency readiness in the uniform success envelope", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/health")
|
||||
.expect(200);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "OK",
|
||||
message: "success",
|
||||
data: { status: "ok", postgres: "up", redis: "up" },
|
||||
});
|
||||
expect(response.body.requestId).toEqual(expect.any(String));
|
||||
expect(response.headers["x-request-id"]).toBe(response.body.requestId);
|
||||
});
|
||||
|
||||
it("passes through a safe caller request id", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("x-request-id", "client_req-123")
|
||||
.expect(200);
|
||||
expect(response.body.requestId).toBe("client_req-123");
|
||||
expect(response.headers["x-request-id"]).toBe("client_req-123");
|
||||
});
|
||||
|
||||
it.each(["x".repeat(129), "bad value"])(
|
||||
"replaces unsafe request id %s",
|
||||
async (unsafeId) => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("x-request-id", unsafeId)
|
||||
.expect(200);
|
||||
expect(response.body.requestId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(response.body.requestId).not.toBe(unsafeId);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns a uniform 404 envelope", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/missing")
|
||||
.expect(404);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Not Found",
|
||||
data: null,
|
||||
});
|
||||
expect(response.body.requestId).toBe(response.headers["x-request-id"]);
|
||||
});
|
||||
|
||||
it("returns a domain exception envelope and status", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/probe/domain-error")
|
||||
.expect(422);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "CONTENT_REJECTED",
|
||||
message: "content rejected",
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak internal errors", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/api/v1/probe/internal-error")
|
||||
.expect(500);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "INTERNAL_ERROR",
|
||||
message: "Internal server error",
|
||||
data: null,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain("postgresql://");
|
||||
expect(response.body).not.toHaveProperty("stack");
|
||||
});
|
||||
|
||||
it("rejects non-whitelisted DTO properties in the uniform envelope", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v1/probe")
|
||||
.send({ name: "ok", extra: true })
|
||||
.expect(400);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "VALIDATION_ERROR",
|
||||
data: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies helmet and same-origin credentialed CORS", async () => {
|
||||
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
|
||||
const allowed = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("Origin", allowedOrigin)
|
||||
.expect(200);
|
||||
expect(allowed.headers["access-control-allow-origin"]).toBe(allowedOrigin);
|
||||
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
|
||||
expect(allowed.headers["x-content-type-options"]).toBe("nosniff");
|
||||
const denied = await request(app.getHttpServer())
|
||||
.get("/api/v1/health/live")
|
||||
.set("Origin", "https://evil.example")
|
||||
.expect(200);
|
||||
expect(denied.headers).not.toHaveProperty("access-control-allow-origin");
|
||||
});
|
||||
|
||||
it("returns a sanitized 503 when a real dependency is unavailable", async () => {
|
||||
const originalRedisUrl = process.env.REDIS_URL;
|
||||
process.env.REDIS_URL = "redis://127.0.0.1:1";
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
const unavailableApp = module.createNestApplication();
|
||||
configureApp(unavailableApp);
|
||||
await unavailableApp.init();
|
||||
try {
|
||||
const response = await request(unavailableApp.getHttpServer())
|
||||
.get("/api/v1/health")
|
||||
.expect(503);
|
||||
expect(response.body).toMatchObject({
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Dependencies unavailable",
|
||||
data: null,
|
||||
});
|
||||
expect(JSON.stringify(response.body)).not.toContain("redis://");
|
||||
} finally {
|
||||
await unavailableApp.close();
|
||||
process.env.REDIS_URL = originalRedisUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaService } from "../database/prisma.service.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
import { HealthService } from "./health.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService, PrismaService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Inject, Injectable, type OnModuleDestroy } from "@nestjs/common";
|
||||
import { createClient, type RedisClientType } from "redis";
|
||||
import { PrismaService } from "../database/prisma.service.js";
|
||||
import { DomainException } from "../common/domain.exception.js";
|
||||
import { ErrorCode } from "@drift/contracts";
|
||||
import { HttpStatus } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class HealthService implements OnModuleDestroy {
|
||||
private readonly redis: RedisClientType;
|
||||
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {
|
||||
this.redis = createClient({
|
||||
url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379",
|
||||
socket: { connectTimeout: 500, reconnectStrategy: false },
|
||||
});
|
||||
this.redis.on("error", () => undefined);
|
||||
}
|
||||
|
||||
async check(): Promise<{ status: "ok"; postgres: "up"; redis: "up" }> {
|
||||
try {
|
||||
if (!this.redis.isOpen) await this.redis.connect();
|
||||
await Promise.all([this.prisma.$queryRaw`SELECT 1`, this.redis.ping()]);
|
||||
return { status: "ok", postgres: "up", redis: "up" };
|
||||
} catch {
|
||||
throw new DomainException(
|
||||
ErrorCode.SERVICE_UNAVAILABLE,
|
||||
"Dependencies unavailable",
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.redis.isOpen) await this.redis.quit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import "reflect-metadata";
|
||||
import { ValidationPipe, type INestApplication } from "@nestjs/common";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import helmet from "helmet";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { HttpResponseInterceptor } from "./common/http-response.interceptor.js";
|
||||
import { DomainExceptionFilter } from "./common/domain-exception.filter.js";
|
||||
|
||||
export function configureApp(app: INestApplication): void {
|
||||
const allowedOrigin = process.env.WEB_ORIGIN ?? "http://localhost:3000";
|
||||
app.setGlobalPrefix("api/v1");
|
||||
app.use(helmet());
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalInterceptors(new HttpResponseInterceptor());
|
||||
app.useGlobalFilters(new DomainExceptionFilter());
|
||||
app.enableCors({
|
||||
origin(
|
||||
origin: string | undefined,
|
||||
callback: (error: Error | null, allow?: boolean) => void,
|
||||
) {
|
||||
callback(null, origin === undefined || origin === allowedOrigin);
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
configureApp(app);
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(Number(process.env.PORT ?? 3001), "0.0.0.0");
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "test") void bootstrap();
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
esbuild: {
|
||||
tsconfigRaw: {
|
||||
compilerOptions: {
|
||||
experimentalDecorators: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*spec.ts"],
|
||||
},
|
||||
});
|
||||
+2
-1
@@ -20,7 +20,8 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"deepmerge-ts@<8.0.0": "8.0.0",
|
||||
"effect@<3.20.0": "3.20.0"
|
||||
"effect@<3.20.0": "3.20.0",
|
||||
"multer@<2.3.0": "2.3.0"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -22,6 +22,10 @@ describe("contracts", () => {
|
||||
it("defines the complete error-code contract", () => {
|
||||
expect(Object.values(ErrorCode)).toEqual([
|
||||
"OK",
|
||||
"VALIDATION_ERROR",
|
||||
"NOT_FOUND",
|
||||
"SERVICE_UNAVAILABLE",
|
||||
"INTERNAL_ERROR",
|
||||
"AUTH_TOKEN_EXPIRED",
|
||||
"AUTH_REFRESH_REUSED",
|
||||
"BOTTLE_DAILY_LIMIT",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export enum ErrorCode {
|
||||
OK = "OK",
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE",
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR",
|
||||
AUTH_TOKEN_EXPIRED = "AUTH_TOKEN_EXPIRED",
|
||||
AUTH_REFRESH_REUSED = "AUTH_REFRESH_REUSED",
|
||||
BOTTLE_DAILY_LIMIT = "BOTTLE_DAILY_LIMIT",
|
||||
@@ -18,6 +22,13 @@ export interface SuccessResponse<T> {
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
code: Exclude<ErrorCode, ErrorCode.OK>;
|
||||
message: string;
|
||||
data: null;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export const ok = <T>(data: T, requestId: string): SuccessResponse<T> => ({
|
||||
code: ErrorCode.OK,
|
||||
message: "success",
|
||||
|
||||
Generated
+1674
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,2 +1,3 @@
|
||||
packages:
|
||||
- packages/*
|
||||
- "packages/*"
|
||||
- "apps/*"
|
||||
|
||||
@@ -29,5 +29,5 @@ describe("seed", () => {
|
||||
prisma.bottle.count({ where: { poolStatus: "IN_POOL" } }),
|
||||
]),
|
||||
).resolves.toEqual([2, 2, 1]);
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
Reference in New Issue
Block a user