nicot 1.2.12 → 1.3.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/README.md +638 -0
- package/dist/index.cjs +1075 -70
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +1080 -54
- package/dist/index.mjs.map +4 -4
- package/dist/src/bases/base-restful-controller.d.ts +2 -1
- package/dist/src/bases/id-base.d.ts +6 -0
- package/dist/src/bases/time-base.d.ts +6 -0
- package/dist/src/crud-base.d.ts +12 -5
- package/dist/src/decorators/access.d.ts +2 -3
- package/dist/src/decorators/index.d.ts +1 -0
- package/dist/src/decorators/property.d.ts +1 -0
- package/dist/src/decorators/upsert.d.ts +5 -0
- package/dist/src/restful.d.ts +31 -19
- package/dist/src/transactional-typeorm.module.d.ts +20 -0
- package/dist/src/utility/create-dynamic-fetcher-proxy.d.ts +1 -0
- package/dist/src/utility/create-inject-from-token-factory.d.ts +4 -0
- package/dist/src/utility/metadata.d.ts +10 -6
- package/index.ts +1 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -808,6 +808,236 @@ You can think of Binding as **“automatic ownership filters”** configured dec
|
|
|
808
808
|
|
|
809
809
|
---
|
|
810
810
|
|
|
811
|
+
## Upsert: Idempotent Write by Conflict Keys (PUT /resource)
|
|
812
|
+
|
|
813
|
+
Upsert is NICOT’s idempotent write primitive. Given a set of conflict keys, NICOT will insert a new row if no existing row matches, or update the matched row if it already exists.
|
|
814
|
+
|
|
815
|
+
Unlike create or update, upsert is a first-class operation with its own semantics:
|
|
816
|
+
- It does not reuse create/update DTO rules by default.
|
|
817
|
+
- It does not rely on create/update lifecycle hooks.
|
|
818
|
+
- It uses explicitly declared upsert keys.
|
|
819
|
+
- It integrates with Binding to remain multi-tenant safe.
|
|
820
|
+
|
|
821
|
+
---
|
|
822
|
+
|
|
823
|
+
### 1) Declaring conflict keys with `@UpsertColumn`
|
|
824
|
+
|
|
825
|
+
`@UpsertColumn()` marks entity fields that participate in the upsert conflict key (often called a “natural key”).
|
|
826
|
+
|
|
827
|
+
Example: upserting an article by `slug`.
|
|
828
|
+
|
|
829
|
+
```ts
|
|
830
|
+
import { Entity } from 'typeorm';
|
|
831
|
+
import { IdBase, StringColumn } from 'nicot';
|
|
832
|
+
import { UpsertColumn, UpsertableEntity } from 'nicot';
|
|
833
|
+
|
|
834
|
+
@Entity()
|
|
835
|
+
@UpsertableEntity()
|
|
836
|
+
export class Article extends IdBase() {
|
|
837
|
+
@UpsertColumn()
|
|
838
|
+
@StringColumn(64, { required: true, description: 'Unique slug per tenant' })
|
|
839
|
+
slug: string;
|
|
840
|
+
|
|
841
|
+
@StringColumn(255, { required: true })
|
|
842
|
+
title: string;
|
|
843
|
+
|
|
844
|
+
isValidInUpsert() {
|
|
845
|
+
return !this.slug ? 'slug is required' : undefined;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
async beforeUpsert() {}
|
|
849
|
+
async afterUpsert() {}
|
|
850
|
+
}
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Notes:
|
|
854
|
+
- Upsert is whitelist-based: only fields decorated with `@UpsertColumn()` can be used as conflict keys.
|
|
855
|
+
- Validation is upsert-specific via `isValidInUpsert()`, parallel to `isValidInCreate()` and `isValidInUpdate()`.
|
|
856
|
+
|
|
857
|
+
---
|
|
858
|
+
|
|
859
|
+
### 2) Enabling upsert with `@UpsertableEntity`
|
|
860
|
+
|
|
861
|
+
`@UpsertableEntity()` marks an entity as upsert-capable and enforces structural correctness.
|
|
862
|
+
|
|
863
|
+
It guarantees that:
|
|
864
|
+
- the entity defines at least one `UpsertColumn` or `BindingColumn` (or has an upsert-capable base id)
|
|
865
|
+
- the effective conflict key is backed by a database-level UNIQUE constraint
|
|
866
|
+
|
|
867
|
+
This aligns with PostgreSQL’s requirement for:
|
|
868
|
+
|
|
869
|
+
`INSERT ... ON CONFLICT (...) DO UPDATE`
|
|
870
|
+
|
|
871
|
+
where the conflict target must match a unique index or unique constraint (the primary key also qualifies).
|
|
872
|
+
|
|
873
|
+
In practice, NICOT builds uniqueness from:
|
|
874
|
+
|
|
875
|
+
- all `@UpsertColumn()` fields
|
|
876
|
+
- all `@BindingColumn()` fields
|
|
877
|
+
|
|
878
|
+
unless the conflict key degenerates to the primary key alone.
|
|
879
|
+
|
|
880
|
+
---
|
|
881
|
+
|
|
882
|
+
### 3) `StringIdBase()` and upsert keys (UUID vs manual)
|
|
883
|
+
|
|
884
|
+
NICOT provides `StringIdBase()` as the string-primary-key base.
|
|
885
|
+
|
|
886
|
+
- `StringIdBase({ uuid: true })`
|
|
887
|
+
- id is generated by the DB
|
|
888
|
+
- id is not client-writable
|
|
889
|
+
- upsert keys should usually be explicit natural keys via `@UpsertColumn()` (and possibly binding columns)
|
|
890
|
+
|
|
891
|
+
- `StringIdBase({ uuid: false })` (or omitted)
|
|
892
|
+
- id is client-provided and required
|
|
893
|
+
- id is effectively the natural key
|
|
894
|
+
- upsert can conflict on `id` alone, meaning this mode behaves as if `id` is an upsert key out of the box
|
|
895
|
+
|
|
896
|
+
This is important when you combine `id` with additional `@UpsertColumn()` fields:
|
|
897
|
+
- If your conflict key includes both `id` and another field (e.g. `slug`), then `id` differs but `slug` matches should be treated as a different row (because the conflict key is the full tuple).
|
|
898
|
+
- If you instead want “slug decides identity” independent of `id`, do not include `id` in the conflict key.
|
|
899
|
+
|
|
900
|
+
(Which key set is used is determined by your upsert columns + binding columns + base id behavior.)
|
|
901
|
+
|
|
902
|
+
---
|
|
903
|
+
|
|
904
|
+
### 4) Upsert and Binding (multi-tenant safety)
|
|
905
|
+
|
|
906
|
+
When Binding is used, binding columns automatically participate in the upsert conflict key.
|
|
907
|
+
|
|
908
|
+
```ts
|
|
909
|
+
import { Entity } from 'typeorm';
|
|
910
|
+
import { IdBase, IntColumn, StringColumn } from 'nicot';
|
|
911
|
+
import { BindingColumn, BindingValue } from 'nicot';
|
|
912
|
+
import { UpsertColumn, UpsertableEntity } from 'nicot';
|
|
913
|
+
import { Injectable } from '@nestjs/common';
|
|
914
|
+
import { CrudService } from 'nicot';
|
|
915
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
916
|
+
|
|
917
|
+
@Entity()
|
|
918
|
+
@UpsertableEntity()
|
|
919
|
+
export class TenantArticle extends IdBase() {
|
|
920
|
+
@BindingColumn('app')
|
|
921
|
+
@IntColumn('int', { unsigned: true })
|
|
922
|
+
appId: number;
|
|
923
|
+
|
|
924
|
+
@UpsertColumn()
|
|
925
|
+
@StringColumn(64)
|
|
926
|
+
slug: string;
|
|
927
|
+
|
|
928
|
+
@StringColumn(255)
|
|
929
|
+
title: string;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
@Injectable()
|
|
933
|
+
export class TenantArticleService extends CrudService(TenantArticle) {
|
|
934
|
+
constructor(@InjectRepository(TenantArticle) repo) {
|
|
935
|
+
super(repo);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
@BindingValue('app')
|
|
939
|
+
get currentAppId() {
|
|
940
|
+
return 44;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
Effective conflict key:
|
|
946
|
+
- appId (binding)
|
|
947
|
+
- slug (upsert column)
|
|
948
|
+
|
|
949
|
+
This ensures:
|
|
950
|
+
- upsert never matches or overwrites rows belonging to another tenant
|
|
951
|
+
- tenant isolation is enforced declaratively at the entity level
|
|
952
|
+
|
|
953
|
+
---
|
|
954
|
+
|
|
955
|
+
### 5) Exposing upsert in controllers (PUT /resource)
|
|
956
|
+
|
|
957
|
+
Upsert is exposed as a PUT request on the resource root path.
|
|
958
|
+
|
|
959
|
+
Factory definition:
|
|
960
|
+
|
|
961
|
+
```ts
|
|
962
|
+
import { RestfulFactory } from 'nicot';
|
|
963
|
+
|
|
964
|
+
export const ArticleFactory = new RestfulFactory(TenantArticle, {
|
|
965
|
+
relations: ['author'],
|
|
966
|
+
upsertIncludeRelations: true,
|
|
967
|
+
skipNonQueryableFields: true,
|
|
968
|
+
});
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
Service:
|
|
972
|
+
|
|
973
|
+
```ts
|
|
974
|
+
import { Injectable } from '@nestjs/common';
|
|
975
|
+
import { InjectRepository } from '@nestjs/typeorm';
|
|
976
|
+
|
|
977
|
+
@Injectable()
|
|
978
|
+
export class ArticleService extends ArticleFactory.crudService() {
|
|
979
|
+
constructor(@InjectRepository(TenantArticle) repo) {
|
|
980
|
+
super(repo);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Controller:
|
|
986
|
+
|
|
987
|
+
```ts
|
|
988
|
+
import { Controller } from '@nestjs/common';
|
|
989
|
+
|
|
990
|
+
export class UpsertArticleDto extends ArticleFactory.upsertDto {}
|
|
991
|
+
|
|
992
|
+
@Controller('articles')
|
|
993
|
+
export class ArticleController {
|
|
994
|
+
constructor(private readonly service: ArticleService) {}
|
|
995
|
+
|
|
996
|
+
// PUT /articles
|
|
997
|
+
@ArticleFactory.upsert()
|
|
998
|
+
upsert(@ArticleFactory.upsertParam() dto: UpsertArticleDto) {
|
|
999
|
+
return this.service.upsert(dto);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
---
|
|
1005
|
+
|
|
1006
|
+
### 6) Response shape and relations
|
|
1007
|
+
|
|
1008
|
+
Upsert returns NICOT’s standard response envelope.
|
|
1009
|
+
|
|
1010
|
+
When `upsertIncludeRelations` is enabled, NICOT will:
|
|
1011
|
+
- refetch the saved entity using a query builder
|
|
1012
|
+
- apply relation joins based on the factory’s `relations` whitelist
|
|
1013
|
+
- return a fully hydrated entity in the response
|
|
1014
|
+
|
|
1015
|
+
When disabled, upsert returns only the entity’s own columns, which is usually faster and avoids unnecessary joins.
|
|
1016
|
+
|
|
1017
|
+
---
|
|
1018
|
+
|
|
1019
|
+
### 7) Soft delete behavior
|
|
1020
|
+
|
|
1021
|
+
All NICOT base entities include a soft-delete column (`deleteTime`).
|
|
1022
|
+
|
|
1023
|
+
If an upsert matches a row that is currently soft-deleted:
|
|
1024
|
+
- NICOT sets `deleteTime` to `null` during upsert
|
|
1025
|
+
- refetches the row using `withDeleted()`
|
|
1026
|
+
- performs an explicit restore if necessary
|
|
1027
|
+
|
|
1028
|
+
This makes upsert fully idempotent even across delete–recreate cycles.
|
|
1029
|
+
|
|
1030
|
+
---
|
|
1031
|
+
|
|
1032
|
+
### 8) Recommended usage patterns
|
|
1033
|
+
|
|
1034
|
+
- Use stable natural keys (`slug`, `code`, `externalId`) as upsert columns.
|
|
1035
|
+
- Combine upsert columns with binding columns for multi-tenant uniqueness.
|
|
1036
|
+
- Keep upsert validation logic inside `isValidInUpsert()`.
|
|
1037
|
+
- For `StringIdBase({ uuid: false })`, treat `id` as the natural key by default; add extra upsert columns only when you explicitly want a composite identity.
|
|
1038
|
+
|
|
1039
|
+
---
|
|
1040
|
+
|
|
811
1041
|
## Pagination
|
|
812
1042
|
|
|
813
1043
|
### Offset pagination (default)
|
|
@@ -1169,6 +1399,414 @@ You can still build custom endpoints and return these wrappers manually if neede
|
|
|
1169
1399
|
|
|
1170
1400
|
---
|
|
1171
1401
|
|
|
1402
|
+
## Transactional TypeORM (request-scoped transactions)
|
|
1403
|
+
|
|
1404
|
+
NICOT’s CRUD flows are TypeORM-based, but by default each repository call is not automatically wrapped in a single database transaction.
|
|
1405
|
+
|
|
1406
|
+
If you want **“one HTTP request = one DB transaction”**, NICOT provides a small TypeORM wrapper:
|
|
1407
|
+
|
|
1408
|
+
- `TransactionalTypeOrmInterceptor()` — starts a TypeORM transaction at the beginning of a request and commits or rolls it back when request processing finishes or fails.
|
|
1409
|
+
- `TransactionalTypeOrmModule.forFeature(...)` — provides **request-scoped** transaction-aware `EntityManager` / `Repository` injection tokens, and also includes the TypeORM `forFeature()` import/export.
|
|
1410
|
+
|
|
1411
|
+
### When to use
|
|
1412
|
+
|
|
1413
|
+
Use transactional mode when you want:
|
|
1414
|
+
|
|
1415
|
+
- multiple writes across different repositories to **commit/rollback together**
|
|
1416
|
+
- service methods that mix `create/update/delete` and custom repo operations
|
|
1417
|
+
- deterministic rollback when you throw `BlankReturnMessageDto(...).toException()`
|
|
1418
|
+
|
|
1419
|
+
Avoid it for:
|
|
1420
|
+
|
|
1421
|
+
- streaming responses (SSE / long-lived streams) — the transaction would stay open until the stream completes
|
|
1422
|
+
- very heavy read-only endpoints where a transaction adds overhead
|
|
1423
|
+
|
|
1424
|
+
### 1) Import TransactionalTypeOrmModule
|
|
1425
|
+
|
|
1426
|
+
In the module that owns your resource:
|
|
1427
|
+
|
|
1428
|
+
```ts
|
|
1429
|
+
import { Module } from '@nestjs/common';
|
|
1430
|
+
import { User } from './user.entity';
|
|
1431
|
+
import { UserController } from './user.controller';
|
|
1432
|
+
import { UserService } from './user.service';
|
|
1433
|
+
|
|
1434
|
+
import { TransactionalTypeOrmModule } from 'nicot'; // or your local path
|
|
1435
|
+
|
|
1436
|
+
@Module({
|
|
1437
|
+
imports: [
|
|
1438
|
+
// ⭐ includes TypeOrmModule.forFeature([User]) internally and re-exports it
|
|
1439
|
+
TransactionalTypeOrmModule.forFeature([User]),
|
|
1440
|
+
],
|
|
1441
|
+
controllers: [UserController],
|
|
1442
|
+
providers: [UserService],
|
|
1443
|
+
})
|
|
1444
|
+
export class UserModule {}
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
Notes:
|
|
1448
|
+
|
|
1449
|
+
- The providers created by `TransactionalTypeOrmModule.forFeature(...)` are `Scope.REQUEST`.
|
|
1450
|
+
- You still need a `TypeOrmModule.forRoot(...)` (or equivalent) at app root to configure the DataSource.
|
|
1451
|
+
|
|
1452
|
+
### 2) Enable TransactionalTypeOrmInterceptor() for the controller
|
|
1453
|
+
|
|
1454
|
+
Apply the interceptor to ensure a transaction is created for each HTTP request:
|
|
1455
|
+
|
|
1456
|
+
```
|
|
1457
|
+
import { Controller, UseInterceptors } from '@nestjs/common';
|
|
1458
|
+
import { RestfulFactory } from 'nicot';
|
|
1459
|
+
import { User } from './user.entity';
|
|
1460
|
+
import { TransactionalTypeOrmInterceptor } from 'nicot';
|
|
1461
|
+
|
|
1462
|
+
export const UserFactory = new RestfulFactory(User);
|
|
1463
|
+
|
|
1464
|
+
@Controller('users')
|
|
1465
|
+
@UseInterceptors(TransactionalTypeOrmInterceptor())
|
|
1466
|
+
export class UserController extends UserFactory.baseController() {
|
|
1467
|
+
constructor(service: UserService) {
|
|
1468
|
+
super(service);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
```
|
|
1472
|
+
|
|
1473
|
+
Behavior:
|
|
1474
|
+
|
|
1475
|
+
- Transaction begins before controller handler runs.
|
|
1476
|
+
- Transaction commits when the returned Observable completes.
|
|
1477
|
+
- Transaction rolls back when the Observable errors (including thrown HTTP exceptions).
|
|
1478
|
+
|
|
1479
|
+
### 3) Inject Transactional Repository / EntityManager in services
|
|
1480
|
+
|
|
1481
|
+
To actually use the transaction context, inject the transactional repo/em instead of the default TypeORM ones.
|
|
1482
|
+
|
|
1483
|
+
#### Transactional repository (recommended)
|
|
1484
|
+
|
|
1485
|
+
```ts
|
|
1486
|
+
import { Injectable } from '@nestjs/common';
|
|
1487
|
+
import { Repository } from 'typeorm';
|
|
1488
|
+
import { RestfulFactory } from 'nicot';
|
|
1489
|
+
import { InjectTransactionalRepository } from 'nicot';
|
|
1490
|
+
import { User } from './user.entity';
|
|
1491
|
+
|
|
1492
|
+
export const UserFactory = new RestfulFactory(User);
|
|
1493
|
+
|
|
1494
|
+
@Injectable()
|
|
1495
|
+
export class UserService extends UserFactory.crudService() {
|
|
1496
|
+
constructor(
|
|
1497
|
+
@InjectTransactionalRepository(User)
|
|
1498
|
+
repo: Repository<User>,
|
|
1499
|
+
) {
|
|
1500
|
+
super(repo);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
```
|
|
1504
|
+
|
|
1505
|
+
Now all NICOT CRUD operations (`create/findAll/update/delete/import`) will run using the transaction-bound repository when the interceptor is active.
|
|
1506
|
+
|
|
1507
|
+
#### Transactional entity manager (advanced)
|
|
1508
|
+
|
|
1509
|
+
```ts
|
|
1510
|
+
import { Injectable } from '@nestjs/common';
|
|
1511
|
+
import { EntityManager } from 'typeorm';
|
|
1512
|
+
import { InjectTransactionalEntityManager } from 'nicot';
|
|
1513
|
+
|
|
1514
|
+
@Injectable()
|
|
1515
|
+
export class UserTxService {
|
|
1516
|
+
constructor(
|
|
1517
|
+
@InjectTransactionalEntityManager()
|
|
1518
|
+
private readonly em: EntityManager,
|
|
1519
|
+
) {}
|
|
1520
|
+
|
|
1521
|
+
async doSomethingComplex() {
|
|
1522
|
+
// this.em is transaction-bound when interceptor is active
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
```
|
|
1526
|
+
|
|
1527
|
+
### Rollback example (404)
|
|
1528
|
+
|
|
1529
|
+
If you throw a NICOT exception after writing, the transaction will roll back:
|
|
1530
|
+
|
|
1531
|
+
```ts
|
|
1532
|
+
@Post('fail')
|
|
1533
|
+
async fail() {
|
|
1534
|
+
await this.service.repo.save({ name: 'ROLL' } as any);
|
|
1535
|
+
throw new BlankReturnMessageDto(404, 'message').toException();
|
|
1536
|
+
}
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1539
|
+
Expected:
|
|
1540
|
+
|
|
1541
|
+
- HTTP response is `404`
|
|
1542
|
+
- database changes are not committed (rollback)
|
|
1543
|
+
|
|
1544
|
+
---
|
|
1545
|
+
|
|
1546
|
+
## Operation: Atomic business logic on a single entity
|
|
1547
|
+
|
|
1548
|
+
NICOT provides an **`operation` abstraction** for implementing
|
|
1549
|
+
**atomic, row-level business logic** on top of a `CrudService`.
|
|
1550
|
+
|
|
1551
|
+
This abstraction exists on **two different layers**:
|
|
1552
|
+
|
|
1553
|
+
1. **Service layer**: `CrudService.operation()`
|
|
1554
|
+
2. **Controller layer**: `RestfulFactory.operation()`
|
|
1555
|
+
|
|
1556
|
+
They are designed to be **orthogonal**:
|
|
1557
|
+
- the service operation **executes domain logic**
|
|
1558
|
+
- the factory operation **exposes it as an HTTP endpoint**
|
|
1559
|
+
|
|
1560
|
+
You may use either one independently, or combine them.
|
|
1561
|
+
|
|
1562
|
+
---
|
|
1563
|
+
|
|
1564
|
+
### Service-level operation (`CrudService.operation()`)
|
|
1565
|
+
|
|
1566
|
+
#### What it does
|
|
1567
|
+
|
|
1568
|
+
`CrudService.operation()` executes a callback with:
|
|
1569
|
+
|
|
1570
|
+
- a **row-level write lock** on the target entity
|
|
1571
|
+
- a transactional repository (unless one is explicitly provided)
|
|
1572
|
+
- automatic **change tracking and flushing**
|
|
1573
|
+
- full compatibility with NICOT binding (`@BindingColumn`, `@BindingValue`)
|
|
1574
|
+
|
|
1575
|
+
Internally, it follows this lifecycle:
|
|
1576
|
+
|
|
1577
|
+
1. Resolve binding values (tenant / owner isolation)
|
|
1578
|
+
2. Check entity existence
|
|
1579
|
+
3. Open a transaction (unless `options.repo` is provided)
|
|
1580
|
+
4. Load the entity with `pessimistic_write` lock
|
|
1581
|
+
5. Snapshot column values
|
|
1582
|
+
6. Run user callback
|
|
1583
|
+
7. Flush only changed columns
|
|
1584
|
+
8. Commit or rollback
|
|
1585
|
+
|
|
1586
|
+
---
|
|
1587
|
+
|
|
1588
|
+
#### Basic usage (inside a service)
|
|
1589
|
+
|
|
1590
|
+
```ts
|
|
1591
|
+
@Injectable()
|
|
1592
|
+
class UserService extends UserFactory.crudService() {
|
|
1593
|
+
async disableUser(id: number) {
|
|
1594
|
+
return this.operation(id, async (user) => {
|
|
1595
|
+
user.isActive = false;
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
```
|
|
1600
|
+
|
|
1601
|
+
Return behavior:
|
|
1602
|
+
|
|
1603
|
+
- callback returns `void | undefined | null`
|
|
1604
|
+
→ `BlankReturnMessageDto(200, 'success')`
|
|
1605
|
+
- callback returns a value
|
|
1606
|
+
→ `GenericReturnMessageDto(200, 'success', value)`
|
|
1607
|
+
|
|
1608
|
+
---
|
|
1609
|
+
|
|
1610
|
+
#### Returning business data
|
|
1611
|
+
|
|
1612
|
+
```ts
|
|
1613
|
+
async disableAndReport(id: number) {
|
|
1614
|
+
return this.operation(id, async (user) => {
|
|
1615
|
+
user.isActive = false;
|
|
1616
|
+
return { disabled: true };
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
```
|
|
1620
|
+
|
|
1621
|
+
---
|
|
1622
|
+
|
|
1623
|
+
#### Error handling & rollback
|
|
1624
|
+
|
|
1625
|
+
Any exception thrown inside the callback causes a rollback.
|
|
1626
|
+
|
|
1627
|
+
```ts
|
|
1628
|
+
async dangerousOperation(id: number) {
|
|
1629
|
+
return this.operation(id, async () => {
|
|
1630
|
+
throw new BlankReturnMessageDto(403, 'Forbidden').toException();
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
```
|
|
1634
|
+
|
|
1635
|
+
---
|
|
1636
|
+
|
|
1637
|
+
#### Binding-aware by default
|
|
1638
|
+
|
|
1639
|
+
`operation()` never bypasses NICOT binding rules.
|
|
1640
|
+
|
|
1641
|
+
If your entity has:
|
|
1642
|
+
|
|
1643
|
+
```ts
|
|
1644
|
+
@BindingColumn()
|
|
1645
|
+
userId: number;
|
|
1646
|
+
```
|
|
1647
|
+
|
|
1648
|
+
and your service defines:
|
|
1649
|
+
|
|
1650
|
+
```ts
|
|
1651
|
+
@BindingValue()
|
|
1652
|
+
get currentUserId() {
|
|
1653
|
+
return this.ctx.userId;
|
|
1654
|
+
}
|
|
1655
|
+
```
|
|
1656
|
+
|
|
1657
|
+
then `operation()` will automatically:
|
|
1658
|
+
|
|
1659
|
+
- restrict existence checks
|
|
1660
|
+
- restrict row locking
|
|
1661
|
+
- restrict updates
|
|
1662
|
+
|
|
1663
|
+
to the current binding scope.
|
|
1664
|
+
|
|
1665
|
+
---
|
|
1666
|
+
|
|
1667
|
+
#### Using `options.repo` (integration with transactional interceptors)
|
|
1668
|
+
|
|
1669
|
+
By default, `operation()` opens its own transaction.
|
|
1670
|
+
|
|
1671
|
+
If you pass a repository via `options.repo`,
|
|
1672
|
+
**no new transaction will be created**.
|
|
1673
|
+
|
|
1674
|
+
This is intended for integration with
|
|
1675
|
+
`TransactionalTypeOrmModule` and `TransactionalTypeOrmInterceptor`.
|
|
1676
|
+
|
|
1677
|
+
```ts
|
|
1678
|
+
@Injectable()
|
|
1679
|
+
class UserService extends UserFactory.crudService() {
|
|
1680
|
+
constructor(
|
|
1681
|
+
@InjectTransactionalRepository(User)
|
|
1682
|
+
private readonly repo: Repository<User>,
|
|
1683
|
+
) {
|
|
1684
|
+
super(repo);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async updateInsideRequestTransaction(id: number) {
|
|
1688
|
+
return this.operation(
|
|
1689
|
+
id,
|
|
1690
|
+
async (user) => {
|
|
1691
|
+
user.name = 'Updated in request transaction';
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
repo: this.repo,
|
|
1695
|
+
},
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
```
|
|
1700
|
+
|
|
1701
|
+
This allows:
|
|
1702
|
+
|
|
1703
|
+
- request-wide transactions
|
|
1704
|
+
- consistent behavior across multiple service calls
|
|
1705
|
+
- zero coupling between business logic and infrastructure
|
|
1706
|
+
|
|
1707
|
+
---
|
|
1708
|
+
|
|
1709
|
+
### Controller-level operation (`RestfulFactory.operation()`)
|
|
1710
|
+
|
|
1711
|
+
#### What it does (and what it does NOT)
|
|
1712
|
+
|
|
1713
|
+
`RestfulFactory.operation()` **does not implement any business logic**.
|
|
1714
|
+
|
|
1715
|
+
It only:
|
|
1716
|
+
|
|
1717
|
+
- declares an HTTP endpoint
|
|
1718
|
+
- wires Swagger metadata
|
|
1719
|
+
- standardizes request / response shape
|
|
1720
|
+
- delegates execution to your service method
|
|
1721
|
+
|
|
1722
|
+
Think of it as **a declarative endpoint generator**, not an executor.
|
|
1723
|
+
|
|
1724
|
+
---
|
|
1725
|
+
|
|
1726
|
+
#### Declaring an operation endpoint
|
|
1727
|
+
|
|
1728
|
+
```ts
|
|
1729
|
+
@Controller('users')
|
|
1730
|
+
export class UserController {
|
|
1731
|
+
constructor(private readonly userService: UserService) {}
|
|
1732
|
+
|
|
1733
|
+
@UserFactory.operation('disable')
|
|
1734
|
+
async disable(@UserFactory.idParam() id: number) {
|
|
1735
|
+
return this.userService.disableUser(id);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
```
|
|
1739
|
+
|
|
1740
|
+
This generates:
|
|
1741
|
+
|
|
1742
|
+
- `POST /users/:id/disable`
|
|
1743
|
+
- Swagger operation summary
|
|
1744
|
+
- standardized NICOT response envelope
|
|
1745
|
+
|
|
1746
|
+
---
|
|
1747
|
+
|
|
1748
|
+
#### Returning custom data
|
|
1749
|
+
|
|
1750
|
+
```ts
|
|
1751
|
+
@UserFactory.operation('reset-password', {
|
|
1752
|
+
returnType: ResetPasswordResultDto,
|
|
1753
|
+
})
|
|
1754
|
+
async resetPassword(@UserFactory.idParam() id: number) {
|
|
1755
|
+
return this.userService.resetPassword(id);
|
|
1756
|
+
}
|
|
1757
|
+
```
|
|
1758
|
+
|
|
1759
|
+
---
|
|
1760
|
+
|
|
1761
|
+
### Combining both layers (recommended pattern)
|
|
1762
|
+
|
|
1763
|
+
The **recommended pattern** is:
|
|
1764
|
+
|
|
1765
|
+
- put **all business logic** in `CrudService.operation()`
|
|
1766
|
+
- expose it via `RestfulFactory.operation()`
|
|
1767
|
+
|
|
1768
|
+
This gives you:
|
|
1769
|
+
|
|
1770
|
+
- reusable domain logic
|
|
1771
|
+
- testable service methods
|
|
1772
|
+
- thin, declarative controllers
|
|
1773
|
+
|
|
1774
|
+
```ts
|
|
1775
|
+
@Injectable()
|
|
1776
|
+
class UserService extends UserFactory.crudService() {
|
|
1777
|
+
async disableUser(id: number) {
|
|
1778
|
+
return this.operation(id, async (user) => {
|
|
1779
|
+
user.isActive = false;
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
@Controller('users')
|
|
1785
|
+
class UserController {
|
|
1786
|
+
constructor(private readonly userService: UserService) {}
|
|
1787
|
+
|
|
1788
|
+
@UserFactory.operation('disable')
|
|
1789
|
+
disable(@UserFactory.idParam() id: number) {
|
|
1790
|
+
return this.userService.disableUser(id);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
```
|
|
1794
|
+
|
|
1795
|
+
---
|
|
1796
|
+
|
|
1797
|
+
### Design philosophy
|
|
1798
|
+
|
|
1799
|
+
- `operation()` is **not** a CRUD replacement
|
|
1800
|
+
- it is **not** a generic transaction wrapper
|
|
1801
|
+
- it is a **domain-oriented mutation primitive**
|
|
1802
|
+
|
|
1803
|
+
In NICOT:
|
|
1804
|
+
|
|
1805
|
+
> **CRUD is declarative**
|
|
1806
|
+
> **Operations express intent**
|
|
1807
|
+
|
|
1808
|
+
---
|
|
1809
|
+
|
|
1172
1810
|
## Best practices
|
|
1173
1811
|
|
|
1174
1812
|
- **One factory per entity**, in its own `*.factory.ts` file.
|