nicot 1.2.13 → 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 +230 -0
- package/dist/index.cjs +343 -79
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +325 -63
- 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/utility/metadata.d.ts +10 -6
- package/package.json +1 -1
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)
|