@devstroupe/devkit-nest 1.1.15 → 1.2.0-beta.1
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/README.md +35 -55
- package/dist/application/crud-use-cases.d.ts +35 -0
- package/dist/application/crud-use-cases.js +44 -0
- package/dist/application/crud-use-cases.test.d.ts +1 -0
- package/dist/application/crud-use-cases.test.js +19 -0
- package/dist/application/index.d.ts +2 -0
- package/dist/application/index.js +18 -0
- package/dist/application/read-model.d.ts +5 -0
- package/dist/application/read-model.js +2 -0
- package/dist/context/execution-context.d.ts +19 -0
- package/dist/context/execution-context.interceptor.d.ts +5 -0
- package/dist/context/execution-context.interceptor.js +35 -0
- package/dist/context/execution-context.interceptor.test.d.ts +1 -0
- package/dist/context/execution-context.interceptor.test.js +26 -0
- package/dist/context/execution-context.js +38 -0
- package/dist/context/execution-context.test.d.ts +1 -0
- package/dist/context/execution-context.test.js +18 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +18 -0
- package/dist/database/base.repository.d.ts +25 -13
- package/dist/database/base.repository.js +63 -29
- package/dist/database/base.repository.test.d.ts +1 -0
- package/dist/database/base.repository.test.js +46 -0
- package/dist/database/typeorm-explain.d.ts +3 -0
- package/dist/database/typeorm-explain.js +8 -0
- package/dist/database/typeorm-explain.test.d.ts +1 -0
- package/dist/database/typeorm-explain.test.js +15 -0
- package/dist/database/typeorm-query.builder.d.ts +36 -8
- package/dist/database/typeorm-query.builder.js +150 -165
- package/dist/database/typeorm-query.builder.test.js +32 -1
- package/dist/database/typeorm-reader.d.ts +9 -0
- package/dist/database/typeorm-reader.js +49 -0
- package/dist/database/typeorm-reader.test.d.ts +1 -0
- package/dist/database/typeorm-reader.test.js +55 -0
- package/dist/database/typeorm-sql.d.ts +3 -0
- package/dist/database/typeorm-sql.js +7 -0
- package/dist/database/typeorm-sql.test.d.ts +1 -0
- package/dist/database/typeorm-sql.test.js +21 -0
- package/dist/database/typeorm-unit-of-work.d.ts +13 -0
- package/dist/database/typeorm-unit-of-work.js +16 -0
- package/dist/database/typeorm-unit-of-work.test.d.ts +1 -0
- package/dist/database/typeorm-unit-of-work.test.js +34 -0
- package/dist/http/base-crud.controller.d.ts +26 -10
- package/dist/http/base-crud.controller.js +46 -36
- package/dist/http/base-crud.controller.test.d.ts +1 -0
- package/dist/http/base-crud.controller.test.js +18 -0
- package/dist/http/module-http-proxy.d.ts +16 -0
- package/dist/http/module-http-proxy.js +160 -0
- package/dist/http/module-http-proxy.test.d.ts +1 -0
- package/dist/http/module-http-proxy.test.js +37 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/platform/index.d.ts +3 -0
- package/dist/platform/index.js +19 -0
- package/dist/platform/integrations/company-registry.d.ts +16 -0
- package/dist/platform/integrations/company-registry.js +2 -0
- package/dist/platform/integrations/external-http.d.ts +38 -0
- package/dist/platform/integrations/external-http.js +78 -0
- package/dist/platform/integrations/external-http.test.d.ts +1 -0
- package/dist/platform/integrations/external-http.test.js +42 -0
- package/dist/platform/integrations/index.d.ts +3 -0
- package/dist/platform/integrations/index.js +19 -0
- package/dist/platform/integrations/webhook.d.ts +45 -0
- package/dist/platform/integrations/webhook.js +74 -0
- package/dist/platform/integrations/webhook.test.d.ts +1 -0
- package/dist/platform/integrations/webhook.test.js +66 -0
- package/dist/platform/messaging/index.d.ts +1 -0
- package/dist/platform/messaging/index.js +17 -0
- package/dist/platform/messaging/outbox.d.ts +56 -0
- package/dist/platform/messaging/outbox.js +90 -0
- package/dist/platform/messaging/outbox.test.d.ts +1 -0
- package/dist/platform/messaging/outbox.test.js +65 -0
- package/dist/platform/security/index.d.ts +2 -0
- package/dist/platform/security/index.js +18 -0
- package/dist/platform/security/password-hasher.d.ts +14 -0
- package/dist/platform/security/password-hasher.js +20 -0
- package/dist/platform/security/security.test.d.ts +1 -0
- package/dist/platform/security/security.test.js +24 -0
- package/dist/platform/security/token.d.ts +22 -0
- package/dist/platform/security/token.js +44 -0
- package/dist/realtime/index.d.ts +1 -0
- package/dist/realtime/index.js +1 -0
- package/dist/realtime/realtime.gateway.d.ts +4 -1
- package/dist/realtime/realtime.gateway.js +10 -38
- package/dist/realtime/realtime.gateway.test.d.ts +1 -0
- package/dist/realtime/realtime.gateway.test.js +34 -0
- package/dist/realtime/realtime.module.d.ts +7 -0
- package/dist/realtime/realtime.module.js +17 -6
- package/dist/realtime/realtime.tokens.d.ts +1 -0
- package/dist/realtime/realtime.tokens.js +4 -0
- package/dist/storage/drivers/local.storage.js +8 -1
- package/package.json +18 -14
package/README.md
CHANGED
|
@@ -1,72 +1,52 @@
|
|
|
1
1
|
# @devstroupe/devkit-nest
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](../../LICENSE)
|
|
5
|
-
|
|
6
|
-
Classes base para **NestJS + TypeORM** do DT-DevKit. Provê repositórios genéricos paginados e controladores CRUD padronizados.
|
|
3
|
+
Bases NestJS e TypeORM 1.x da arquitetura backend do DT-DevKit.
|
|
7
4
|
|
|
8
5
|
## Instalação
|
|
9
6
|
|
|
10
7
|
```bash
|
|
11
|
-
npm install @devstroupe/devkit-nest
|
|
8
|
+
npm install @devstroupe/devkit-nest typeorm@^1.0.0
|
|
12
9
|
```
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install @nestjs/common @nestjs/core @nestjs/swagger typeorm reflect-metadata
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## Classes disponíveis
|
|
20
|
-
|
|
21
|
-
### `BaseRepository<Entity>`
|
|
11
|
+
Requer Node.js 20+, NestJS 10/11 e `@nestjs/typeorm` compatível com TypeORM 1.x.
|
|
22
12
|
|
|
23
|
-
|
|
13
|
+
## CRUD tipado
|
|
24
14
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
15
|
+
- `BaseCrudController<Entity, Filter, ID, Create, Update, ListResult>` recebe uma
|
|
16
|
+
aplicação/use cases tipados e um `IdCodec`; há codecs number, string e UUID.
|
|
17
|
+
- `BaseRepository<Entity, ID, Filter, Projection>` permite chave customizada,
|
|
18
|
+
QueryBuilder extensível, escopo tenant e projeção de lista.
|
|
19
|
+
- `CreateUseCase`, `UpdateUseCase`, `FindOneUseCase`, `ListUseCase` e
|
|
20
|
+
`SoftDeleteUseCase` removem repetição sem receber regras de negócio.
|
|
21
|
+
- `TypeOrmQueryBuilder` usa registry de filtros, limites físicos obrigatórios e
|
|
22
|
+
busca full-text MySQL configurada somente por campos confiáveis.
|
|
23
|
+
- `TypeOrmReader` retorna projeções raw tipadas, suporta count separado,
|
|
24
|
+
`COUNT(DISTINCT ...)` e permite omitir o total.
|
|
25
|
+
- `explainQuery` cria um harness de `EXPLAIN` sem perder os parâmetros.
|
|
26
|
+
- `typeOrmSql<Row>(dataSource)` expõe a SQL tagged template parametrizada.
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
constructor(
|
|
34
|
-
@InjectRepository(Cabin)
|
|
35
|
-
private readonly repo: Repository<Cabin>
|
|
36
|
-
) {
|
|
37
|
-
super(repo);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
Métodos herdados:
|
|
43
|
-
- `findMany(query)` — paginação automática com filtros
|
|
44
|
-
- `findById(id)` — busca por ID com validação
|
|
45
|
-
|
|
46
|
-
### `BaseCrudController<Entity, CreateDto, UpdateDto>`
|
|
28
|
+
Toda deleção do repositório base exige `@DeleteDateColumn` e executa soft delete.
|
|
29
|
+
Não há restore público.
|
|
47
30
|
|
|
48
|
-
|
|
31
|
+
## Contexto e transações
|
|
49
32
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
) {}
|
|
57
|
-
```
|
|
33
|
+
- `executionContext` / `AppExecutionContext`: actor, tenant, correlation ID e
|
|
34
|
+
source propagados por `AsyncLocalStorage`; `requireTenantId()` falha fechado.
|
|
35
|
+
- `TypeOrmUnitOfWork`: callback transacional com o mesmo `EntityManager` para
|
|
36
|
+
todos os repositories.
|
|
37
|
+
- `TypeOrmOutboxWriter` e `OutboxPublisher`: persistência atômica, retry e ID de
|
|
38
|
+
evento como chave idempotente.
|
|
58
39
|
|
|
59
|
-
|
|
60
|
-
- `GET /` — listagem paginada com filtros via query params
|
|
61
|
-
- `GET /:id` — busca por ID
|
|
62
|
-
- `POST /` — criação
|
|
63
|
-
- `PATCH /:id` — atualização parcial
|
|
64
|
-
- `DELETE /:id` — remoção
|
|
40
|
+
## Platform
|
|
65
41
|
|
|
66
|
-
|
|
42
|
+
- security: `PasswordHasher`, `BcryptPasswordHasher`, `TokenIssuer`,
|
|
43
|
+
`TokenVerifier` e adapters JWT separados;
|
|
44
|
+
- integrations: `ExternalHttpClient`, `CompanyRegistryGateway` e contratos de
|
|
45
|
+
webhook verificado/idempotente, com replay window, contexto de evento e
|
|
46
|
+
auditoria sem secrets;
|
|
47
|
+
- messaging, storage, auth/RBAC, realtime e audit.
|
|
67
48
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
- Todos os **Repositories** herdem de `BaseRepository`
|
|
49
|
+
`RealtimeModule.register({ tokenVerifier })` recebe somente `TokenVerifier`;
|
|
50
|
+
WebSocket/realtime não importa JWT diretamente nem recebe chave de emissão.
|
|
71
51
|
|
|
72
|
-
|
|
52
|
+
Detalhes e exemplos: [arquitetura backend](../../docs/backend-architecture.md).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DeepPartial, ObjectLiteral } from 'typeorm';
|
|
2
|
+
import type { IFilterPagination, IPaginator } from '@devstroupe/devkit-core';
|
|
3
|
+
export interface CrudRepositoryPort<Entity extends ObjectLiteral, ID, Filter extends IFilterPagination = IFilterPagination, ListProjection = Entity> {
|
|
4
|
+
create(input: DeepPartial<Entity>): Promise<Entity>;
|
|
5
|
+
update(id: ID, input: DeepPartial<Entity>): Promise<Entity>;
|
|
6
|
+
findOne(id: ID): Promise<Entity | null>;
|
|
7
|
+
findMany(filter: Filter): Promise<IPaginator<ListProjection>>;
|
|
8
|
+
delete(id: ID): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export declare class CreateUseCase<Entity extends ObjectLiteral, ID, Filter extends IFilterPagination = IFilterPagination> {
|
|
11
|
+
protected readonly repository: CrudRepositoryPort<Entity, ID, Filter>;
|
|
12
|
+
constructor(repository: CrudRepositoryPort<Entity, ID, Filter>);
|
|
13
|
+
execute(input: DeepPartial<Entity>): Promise<Entity>;
|
|
14
|
+
}
|
|
15
|
+
export declare class UpdateUseCase<Entity extends ObjectLiteral, ID, Filter extends IFilterPagination = IFilterPagination> {
|
|
16
|
+
protected readonly repository: CrudRepositoryPort<Entity, ID, Filter>;
|
|
17
|
+
constructor(repository: CrudRepositoryPort<Entity, ID, Filter>);
|
|
18
|
+
execute(id: ID, input: DeepPartial<Entity>): Promise<Entity>;
|
|
19
|
+
}
|
|
20
|
+
export declare class FindOneUseCase<Entity extends ObjectLiteral, ID, Filter extends IFilterPagination = IFilterPagination> {
|
|
21
|
+
protected readonly repository: CrudRepositoryPort<Entity, ID, Filter>;
|
|
22
|
+
constructor(repository: CrudRepositoryPort<Entity, ID, Filter>);
|
|
23
|
+
execute(id: ID): Promise<Entity | null>;
|
|
24
|
+
}
|
|
25
|
+
export declare class ListUseCase<Entity extends ObjectLiteral, ID, Filter extends IFilterPagination = IFilterPagination, Projection = Entity> {
|
|
26
|
+
protected readonly repository: CrudRepositoryPort<Entity, ID, Filter, Projection>;
|
|
27
|
+
constructor(repository: CrudRepositoryPort<Entity, ID, Filter, Projection>);
|
|
28
|
+
execute(filter: Filter): Promise<IPaginator<Projection>>;
|
|
29
|
+
}
|
|
30
|
+
/** Override execute in a concrete use case when explicit dependent cascades are required. */
|
|
31
|
+
export declare class SoftDeleteUseCase<Entity extends ObjectLiteral, ID, Filter extends IFilterPagination = IFilterPagination> {
|
|
32
|
+
protected readonly repository: CrudRepositoryPort<Entity, ID, Filter>;
|
|
33
|
+
constructor(repository: CrudRepositoryPort<Entity, ID, Filter>);
|
|
34
|
+
execute(id: ID): Promise<void>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SoftDeleteUseCase = exports.ListUseCase = exports.FindOneUseCase = exports.UpdateUseCase = exports.CreateUseCase = void 0;
|
|
4
|
+
class CreateUseCase {
|
|
5
|
+
repository;
|
|
6
|
+
constructor(repository) {
|
|
7
|
+
this.repository = repository;
|
|
8
|
+
}
|
|
9
|
+
execute(input) { return this.repository.create(input); }
|
|
10
|
+
}
|
|
11
|
+
exports.CreateUseCase = CreateUseCase;
|
|
12
|
+
class UpdateUseCase {
|
|
13
|
+
repository;
|
|
14
|
+
constructor(repository) {
|
|
15
|
+
this.repository = repository;
|
|
16
|
+
}
|
|
17
|
+
execute(id, input) { return this.repository.update(id, input); }
|
|
18
|
+
}
|
|
19
|
+
exports.UpdateUseCase = UpdateUseCase;
|
|
20
|
+
class FindOneUseCase {
|
|
21
|
+
repository;
|
|
22
|
+
constructor(repository) {
|
|
23
|
+
this.repository = repository;
|
|
24
|
+
}
|
|
25
|
+
execute(id) { return this.repository.findOne(id); }
|
|
26
|
+
}
|
|
27
|
+
exports.FindOneUseCase = FindOneUseCase;
|
|
28
|
+
class ListUseCase {
|
|
29
|
+
repository;
|
|
30
|
+
constructor(repository) {
|
|
31
|
+
this.repository = repository;
|
|
32
|
+
}
|
|
33
|
+
execute(filter) { return this.repository.findMany(filter); }
|
|
34
|
+
}
|
|
35
|
+
exports.ListUseCase = ListUseCase;
|
|
36
|
+
/** Override execute in a concrete use case when explicit dependent cascades are required. */
|
|
37
|
+
class SoftDeleteUseCase {
|
|
38
|
+
repository;
|
|
39
|
+
constructor(repository) {
|
|
40
|
+
this.repository = repository;
|
|
41
|
+
}
|
|
42
|
+
execute(id) { return this.repository.delete(id); }
|
|
43
|
+
}
|
|
44
|
+
exports.SoftDeleteUseCase = SoftDeleteUseCase;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const crud_use_cases_1 = require("./crud-use-cases");
|
|
9
|
+
(0, node_test_1.default)('CRUD base use cases delegate only application mechanics', async () => {
|
|
10
|
+
const calls = [];
|
|
11
|
+
const repository = {
|
|
12
|
+
async create(input) { calls.push(`create:${input.name}`); return { id: 1, ...input }; },
|
|
13
|
+
async delete(id) { calls.push(`delete:${id}`); },
|
|
14
|
+
};
|
|
15
|
+
const created = await new crud_use_cases_1.CreateUseCase(repository).execute({ name: 'Catalog item' });
|
|
16
|
+
await new crud_use_cases_1.SoftDeleteUseCase(repository).execute(1);
|
|
17
|
+
strict_1.default.deepEqual(created, { id: 1, name: 'Catalog item' });
|
|
18
|
+
strict_1.default.deepEqual(calls, ['create:Catalog item', 'delete:1']);
|
|
19
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./crud-use-cases"), exports);
|
|
18
|
+
__exportStar(require("./read-model"), exports);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { IFilterPagination, IPaginator } from '@devstroupe/devkit-core';
|
|
2
|
+
/** Add a reader port only for a query that justifies a dedicated projection. */
|
|
3
|
+
export interface ReadModelReader<Row, Filter extends IFilterPagination = IFilterPagination> {
|
|
4
|
+
read(filter: Filter): Promise<IPaginator<Row>>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type AppExecutionSource = 'http' | 'rpc' | 'job' | 'event';
|
|
2
|
+
export interface AppExecutionContext {
|
|
3
|
+
actorId?: string;
|
|
4
|
+
tenantId?: string;
|
|
5
|
+
correlationId: string;
|
|
6
|
+
source: AppExecutionSource;
|
|
7
|
+
}
|
|
8
|
+
export declare class MissingTenantContextError extends Error {
|
|
9
|
+
constructor();
|
|
10
|
+
}
|
|
11
|
+
/** Transport-neutral request context shared by HTTP, RPC, jobs and events. */
|
|
12
|
+
export declare class ExecutionContextAccessor {
|
|
13
|
+
private readonly storage;
|
|
14
|
+
run<T>(context: AppExecutionContext, callback: () => T): T;
|
|
15
|
+
current(): AppExecutionContext | undefined;
|
|
16
|
+
requireCurrent(): AppExecutionContext;
|
|
17
|
+
requireTenantId(): string;
|
|
18
|
+
}
|
|
19
|
+
export declare const executionContext: ExecutionContextAccessor;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
export declare class ExecutionContextInterceptor implements NestInterceptor {
|
|
4
|
+
intercept(host: ExecutionContext, next: CallHandler): Observable<unknown>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.ExecutionContextInterceptor = void 0;
|
|
10
|
+
const common_1 = require("@nestjs/common");
|
|
11
|
+
const node_crypto_1 = require("node:crypto");
|
|
12
|
+
const rxjs_1 = require("rxjs");
|
|
13
|
+
const execution_context_1 = require("./execution-context");
|
|
14
|
+
let ExecutionContextInterceptor = class ExecutionContextInterceptor {
|
|
15
|
+
intercept(host, next) {
|
|
16
|
+
const request = host.switchToHttp().getRequest();
|
|
17
|
+
const correlationHeader = request.headers?.['x-correlation-id'];
|
|
18
|
+
const correlationId = (Array.isArray(correlationHeader) ? correlationHeader[0] : correlationHeader) || (0, node_crypto_1.randomUUID)();
|
|
19
|
+
const actor = request.user?.id ?? request.user?.sub;
|
|
20
|
+
const context = {
|
|
21
|
+
actorId: actor === undefined ? undefined : String(actor),
|
|
22
|
+
tenantId: request.user?.tenantId === undefined ? undefined : String(request.user.tenantId),
|
|
23
|
+
correlationId,
|
|
24
|
+
source: 'http',
|
|
25
|
+
};
|
|
26
|
+
return new rxjs_1.Observable((subscriber) => execution_context_1.executionContext.run(context, () => {
|
|
27
|
+
const subscription = next.handle().subscribe(subscriber);
|
|
28
|
+
return () => subscription.unsubscribe();
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
exports.ExecutionContextInterceptor = ExecutionContextInterceptor;
|
|
33
|
+
exports.ExecutionContextInterceptor = ExecutionContextInterceptor = __decorate([
|
|
34
|
+
(0, common_1.Injectable)()
|
|
35
|
+
], ExecutionContextInterceptor);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const rxjs_1 = require("rxjs");
|
|
9
|
+
const execution_context_1 = require("./execution-context");
|
|
10
|
+
const execution_context_interceptor_1 = require("./execution-context.interceptor");
|
|
11
|
+
(0, node_test_1.default)('HTTP interceptor derives tenant from authenticated principal, not a free tenant header', async () => {
|
|
12
|
+
const host = {
|
|
13
|
+
switchToHttp: () => ({ getRequest: () => ({
|
|
14
|
+
user: { id: 7, tenantId: 'trusted-tenant' },
|
|
15
|
+
headers: { 'x-tenant-id': 'attacker-tenant', 'x-correlation-id': 'corr-http' },
|
|
16
|
+
}) }),
|
|
17
|
+
};
|
|
18
|
+
const next = { handle: () => new rxjs_1.Observable((subscriber) => {
|
|
19
|
+
subscriber.next(execution_context_1.executionContext.requireCurrent());
|
|
20
|
+
subscriber.complete();
|
|
21
|
+
}) };
|
|
22
|
+
const context = await (0, rxjs_1.firstValueFrom)(new execution_context_interceptor_1.ExecutionContextInterceptor().intercept(host, next));
|
|
23
|
+
strict_1.default.equal(context.tenantId, 'trusted-tenant');
|
|
24
|
+
strict_1.default.equal(context.actorId, '7');
|
|
25
|
+
strict_1.default.equal(context.correlationId, 'corr-http');
|
|
26
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.executionContext = exports.ExecutionContextAccessor = exports.MissingTenantContextError = void 0;
|
|
4
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
5
|
+
class MissingTenantContextError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super('A trusted tenant is required for this operation.');
|
|
8
|
+
this.name = 'MissingTenantContextError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.MissingTenantContextError = MissingTenantContextError;
|
|
12
|
+
/** Transport-neutral request context shared by HTTP, RPC, jobs and events. */
|
|
13
|
+
class ExecutionContextAccessor {
|
|
14
|
+
storage = new node_async_hooks_1.AsyncLocalStorage();
|
|
15
|
+
run(context, callback) {
|
|
16
|
+
if (!context.correlationId?.trim()) {
|
|
17
|
+
throw new Error('Execution context requires a correlationId.');
|
|
18
|
+
}
|
|
19
|
+
return this.storage.run(Object.freeze({ ...context }), callback);
|
|
20
|
+
}
|
|
21
|
+
current() {
|
|
22
|
+
return this.storage.getStore();
|
|
23
|
+
}
|
|
24
|
+
requireCurrent() {
|
|
25
|
+
const context = this.current();
|
|
26
|
+
if (!context)
|
|
27
|
+
throw new Error('Execution context is unavailable.');
|
|
28
|
+
return context;
|
|
29
|
+
}
|
|
30
|
+
requireTenantId() {
|
|
31
|
+
const tenantId = this.current()?.tenantId;
|
|
32
|
+
if (!tenantId)
|
|
33
|
+
throw new MissingTenantContextError();
|
|
34
|
+
return tenantId;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
exports.ExecutionContextAccessor = ExecutionContextAccessor;
|
|
38
|
+
exports.executionContext = new ExecutionContextAccessor();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const execution_context_1 = require("./execution-context");
|
|
9
|
+
(0, node_test_1.default)('execution context propagates trusted data across async work', async () => {
|
|
10
|
+
await execution_context_1.executionContext.run({ actorId: 'actor-1', tenantId: 'tenant-1', correlationId: 'corr-1', source: 'job' }, async () => {
|
|
11
|
+
await Promise.resolve();
|
|
12
|
+
strict_1.default.equal(execution_context_1.executionContext.requireTenantId(), 'tenant-1');
|
|
13
|
+
strict_1.default.equal(execution_context_1.executionContext.requireCurrent().source, 'job');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
(0, node_test_1.default)('tenant-scoped work fails closed without a tenant', () => {
|
|
17
|
+
execution_context_1.executionContext.run({ correlationId: 'corr-2', source: 'http' }, () => strict_1.default.throws(() => execution_context_1.executionContext.requireTenantId(), execution_context_1.MissingTenantContextError));
|
|
18
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./execution-context"), exports);
|
|
18
|
+
__exportStar(require("./execution-context.interceptor"), exports);
|
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
import { Repository, SelectQueryBuilder
|
|
2
|
-
import {
|
|
1
|
+
import { DeepPartial, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm';
|
|
2
|
+
import { IFilterPagination, IPaginator } from '@devstroupe/devkit-core';
|
|
3
3
|
import { IQueryBuilderOptions } from './typeorm-query.builder';
|
|
4
|
-
export
|
|
4
|
+
export interface BaseRepositoryOptions<Entity extends ObjectLiteral> {
|
|
5
|
+
idColumn?: keyof Entity & string;
|
|
6
|
+
alias?: string;
|
|
7
|
+
/** Trusted server-side scope, normally tenantId. */
|
|
8
|
+
scope?: () => Readonly<Partial<Entity>>;
|
|
9
|
+
}
|
|
10
|
+
export declare abstract class BaseRepository<Entity extends ObjectLiteral, ID = number, FilterDto extends IFilterPagination = IFilterPagination, ListProjection = Entity> {
|
|
5
11
|
protected readonly repository: Repository<Entity>;
|
|
6
|
-
|
|
7
|
-
protected
|
|
8
|
-
protected
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
private readonly options;
|
|
13
|
+
protected allowedFilters?: readonly string[];
|
|
14
|
+
protected allowedSorts?: readonly string[];
|
|
15
|
+
protected searchableFields?: readonly string[];
|
|
16
|
+
protected readonly idColumn: keyof Entity & string;
|
|
17
|
+
protected readonly alias: string;
|
|
18
|
+
constructor(repository: Repository<Entity>, options?: BaseRepositoryOptions<Entity>);
|
|
19
|
+
protected scope(): Readonly<Partial<Entity>>;
|
|
20
|
+
protected query(alias?: string): SelectQueryBuilder<Entity>;
|
|
21
|
+
create(data: DeepPartial<Entity>): Promise<Entity>;
|
|
22
|
+
update(id: ID, data: DeepPartial<Entity>): Promise<Entity>;
|
|
23
|
+
/** Performs one scoped UPDATE through TypeORM soft-delete; no restore API exists. */
|
|
24
|
+
delete(id: ID): Promise<void>;
|
|
25
|
+
findOne(id: ID): Promise<Entity | null>;
|
|
26
|
+
findMany(filter: FilterDto): Promise<IPaginator<ListProjection>>;
|
|
27
|
+
protected paginate<Projection extends ObjectLiteral = Entity>(qb: SelectQueryBuilder<Projection>, filter: FilterDto, options?: IQueryBuilderOptions<Projection>, project?: (entity: Projection) => ListProjection): Promise<IPaginator<ListProjection>>;
|
|
16
28
|
}
|
|
@@ -1,61 +1,95 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BaseRepository = void 0;
|
|
4
|
+
const devkit_core_1 = require("@devstroupe/devkit-core");
|
|
4
5
|
const typeorm_query_builder_1 = require("./typeorm-query.builder");
|
|
5
6
|
class BaseRepository {
|
|
6
7
|
repository;
|
|
8
|
+
options;
|
|
7
9
|
allowedFilters;
|
|
8
10
|
allowedSorts;
|
|
9
11
|
searchableFields;
|
|
10
|
-
|
|
12
|
+
idColumn;
|
|
13
|
+
alias;
|
|
14
|
+
constructor(repository, options = {}) {
|
|
11
15
|
this.repository = repository;
|
|
16
|
+
this.options = options;
|
|
17
|
+
this.idColumn = options.idColumn ?? 'id';
|
|
18
|
+
this.alias = options.alias ?? repository.metadata.name.toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
scope() {
|
|
21
|
+
return this.options.scope?.() ?? {};
|
|
22
|
+
}
|
|
23
|
+
query(alias = this.alias) {
|
|
24
|
+
const qb = this.repository.createQueryBuilder(alias);
|
|
25
|
+
for (const [column, value] of Object.entries(this.scope())) {
|
|
26
|
+
qb.andWhere(`${alias}.${column} = :scope_${column}`, { [`scope_${column}`]: value });
|
|
27
|
+
}
|
|
28
|
+
return qb;
|
|
12
29
|
}
|
|
13
30
|
async create(data) {
|
|
14
|
-
const entity = this.repository.create(data);
|
|
15
|
-
return
|
|
31
|
+
const entity = this.repository.create({ ...data, ...this.scope() });
|
|
32
|
+
return this.repository.save(entity);
|
|
16
33
|
}
|
|
17
34
|
async update(id, data) {
|
|
18
|
-
await this.
|
|
35
|
+
const current = await this.findOne(id);
|
|
36
|
+
if (!current)
|
|
37
|
+
throw new Error(`Entity with ID ${String(id)} not found`);
|
|
38
|
+
const entity = this.repository.merge(current, data, this.scope());
|
|
39
|
+
await this.repository.save(entity);
|
|
19
40
|
const updated = await this.findOne(id);
|
|
20
|
-
if (!updated)
|
|
21
|
-
throw new Error(`Entity with ID ${id} not found after update`);
|
|
22
|
-
}
|
|
41
|
+
if (!updated)
|
|
42
|
+
throw new Error(`Entity with ID ${String(id)} not found after update`);
|
|
23
43
|
return updated;
|
|
24
44
|
}
|
|
45
|
+
/** Performs one scoped UPDATE through TypeORM soft-delete; no restore API exists. */
|
|
25
46
|
async delete(id) {
|
|
26
|
-
|
|
47
|
+
const metadata = this.repository.metadata;
|
|
48
|
+
const deleteColumn = metadata.deleteDateColumn;
|
|
49
|
+
if (!deleteColumn) {
|
|
50
|
+
throw new Error(`${metadata.name} must declare a DeleteDateColumn.`);
|
|
51
|
+
}
|
|
52
|
+
const qb = this.repository.createQueryBuilder(this.alias).softDelete();
|
|
53
|
+
qb.where(`${this.idColumn} = :id`, { id });
|
|
54
|
+
for (const [column, value] of Object.entries(this.scope())) {
|
|
55
|
+
qb.andWhere(`${column} = :scope_${column}`, { [`scope_${column}`]: value });
|
|
56
|
+
}
|
|
57
|
+
qb.andWhere(`${deleteColumn.propertyName} IS NULL`);
|
|
58
|
+
await qb.execute();
|
|
27
59
|
}
|
|
28
60
|
async findOne(id) {
|
|
29
|
-
return
|
|
61
|
+
return this.query()
|
|
62
|
+
.andWhere(`${this.alias}.${this.idColumn} = :id`, { id })
|
|
63
|
+
.getOne();
|
|
30
64
|
}
|
|
31
65
|
async findMany(filter) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
66
|
+
return this.paginate(this.query(), filter);
|
|
67
|
+
}
|
|
68
|
+
async paginate(qb, filter, options = {}, project = (entity) => entity) {
|
|
69
|
+
const configured = typeorm_query_builder_1.TypeOrmQueryBuilder.apply(qb, filter, {
|
|
35
70
|
allowedFilters: this.allowedFilters,
|
|
36
71
|
allowedSorts: this.allowedSorts,
|
|
37
72
|
searchableFields: this.searchableFields,
|
|
73
|
+
...options,
|
|
74
|
+
});
|
|
75
|
+
const includeTotal = filter.includeTotal !== false;
|
|
76
|
+
const [entities, total] = includeTotal
|
|
77
|
+
? await configured.getManyAndCount()
|
|
78
|
+
: [await configured.getMany(), undefined];
|
|
79
|
+
const page = Math.max(1, Number(filter.page) || 1);
|
|
80
|
+
const limit = (0, devkit_core_1.resolvePaginationLimit)(filter.limit, {
|
|
81
|
+
defaultLimit: options.pagination?.defaultLimit ?? 20,
|
|
82
|
+
maxLimit: options.pagination?.maxLimit ?? 100,
|
|
38
83
|
});
|
|
39
|
-
}
|
|
40
|
-
async paginate(qb, filter, options) {
|
|
41
|
-
const queryBuilder = typeorm_query_builder_1.TypeOrmQueryBuilder.apply(qb, filter, options);
|
|
42
|
-
const [items, totalItems] = await queryBuilder.getManyAndCount();
|
|
43
|
-
const page = Number(filter.page) || 1;
|
|
44
|
-
let limit = 10;
|
|
45
|
-
if (filter.limit === 'todos' || filter.limit === 'all') {
|
|
46
|
-
limit = totalItems;
|
|
47
|
-
}
|
|
48
|
-
else if (filter.limit) {
|
|
49
|
-
limit = Number(filter.limit);
|
|
50
|
-
}
|
|
51
84
|
return {
|
|
52
|
-
items,
|
|
85
|
+
items: entities.map(project),
|
|
53
86
|
meta: {
|
|
54
|
-
totalItems,
|
|
87
|
+
totalItems: total,
|
|
55
88
|
currentPage: page,
|
|
56
|
-
itemsPerPage: limit
|
|
57
|
-
itemCount:
|
|
58
|
-
totalPage:
|
|
89
|
+
itemsPerPage: limit,
|
|
90
|
+
itemCount: entities.length,
|
|
91
|
+
totalPage: total === undefined ? undefined : Math.ceil(total / limit),
|
|
92
|
+
hasNextPage: total === undefined ? entities.length === limit : page * limit < total,
|
|
59
93
|
},
|
|
60
94
|
};
|
|
61
95
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const node_test_1 = __importDefault(require("node:test"));
|
|
8
|
+
const base_repository_1 = require("./base.repository");
|
|
9
|
+
(0, node_test_1.default)('BaseRepository soft-delete includes ID, tenant and deleted marker in one command', async () => {
|
|
10
|
+
const conditions = [];
|
|
11
|
+
let executions = 0;
|
|
12
|
+
const deleteBuilder = {
|
|
13
|
+
softDelete() { return this; },
|
|
14
|
+
where(value) { conditions.push(value); return this; },
|
|
15
|
+
andWhere(value) { conditions.push(value); return this; },
|
|
16
|
+
async execute() { executions += 1; },
|
|
17
|
+
};
|
|
18
|
+
const repository = {
|
|
19
|
+
metadata: {
|
|
20
|
+
name: 'Invoice',
|
|
21
|
+
deleteDateColumn: { propertyName: 'deletedAt' },
|
|
22
|
+
},
|
|
23
|
+
createQueryBuilder() { return deleteBuilder; },
|
|
24
|
+
};
|
|
25
|
+
class InvoiceRepository extends base_repository_1.BaseRepository {
|
|
26
|
+
constructor() {
|
|
27
|
+
super(repository, { idColumn: 'uuid', scope: () => ({ tenantId: 'tenant-1' }) });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
await new InvoiceRepository().delete('invoice-1');
|
|
31
|
+
strict_1.default.equal(executions, 1);
|
|
32
|
+
strict_1.default.deepEqual(conditions, [
|
|
33
|
+
'uuid = :id',
|
|
34
|
+
'tenantId = :scope_tenantId',
|
|
35
|
+
'deletedAt IS NULL',
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
(0, node_test_1.default)('BaseRepository refuses deletion when soft-delete metadata is absent', async () => {
|
|
39
|
+
const repository = {
|
|
40
|
+
metadata: { name: 'UnsafeEntity' },
|
|
41
|
+
};
|
|
42
|
+
class UnsafeRepository extends base_repository_1.BaseRepository {
|
|
43
|
+
constructor() { super(repository); }
|
|
44
|
+
}
|
|
45
|
+
await strict_1.default.rejects(() => new UnsafeRepository().delete(1), /DeleteDateColumn/);
|
|
46
|
+
});
|