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 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.