onecore 0.6.6 → 0.6.7
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 +1631 -0
- package/package.json +1 -1
- package/src/db.ts +2 -2
- package/src/index.ts +3 -3
- package/src/service.ts +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,22 @@ Without a shared foundation, each library tends to define its own interfaces and
|
|
|
57
57
|
|
|
58
58
|
The implementation packages depend on these contracts, while application code can depend on the contracts rather than concrete infrastructure implementations.
|
|
59
59
|
|
|
60
|
+
```text
|
|
61
|
+
Application
|
|
62
|
+
│
|
|
63
|
+
▼
|
|
64
|
+
UseCase / Service
|
|
65
|
+
│
|
|
66
|
+
▼
|
|
67
|
+
Repository
|
|
68
|
+
│
|
|
69
|
+
▼
|
|
70
|
+
Executor
|
|
71
|
+
│
|
|
72
|
+
├── DB
|
|
73
|
+
└── Transaction
|
|
74
|
+
```
|
|
75
|
+
|
|
60
76
|
## Features
|
|
61
77
|
|
|
62
78
|
* Shared TypeScript interfaces for the Core TS ecosystem
|
|
@@ -776,3 +792,1618 @@ These projects depend only on interfaces and the shared `Attribute` metadata mod
|
|
|
776
792
|
MIT
|
|
777
793
|
|
|
778
794
|
|
|
795
|
+
# TypeScript Repository & Service Abstraction
|
|
796
|
+
|
|
797
|
+
A lightweight TypeScript abstraction for building database-backed applications using repositories, services/use cases, transactions, and metadata-driven attributes.
|
|
798
|
+
|
|
799
|
+
The library separates database execution from persistence and application-facing operations while keeping the API generic across database implementations.
|
|
800
|
+
|
|
801
|
+
## Architecture
|
|
802
|
+
|
|
803
|
+
```text
|
|
804
|
+
┌──────────────────────────────┐
|
|
805
|
+
│ Service / UseCase │
|
|
806
|
+
│ SearchUseCase │
|
|
807
|
+
│ CRUDUseCase │
|
|
808
|
+
│ UseCase │
|
|
809
|
+
└──────────────┬───────────────┘
|
|
810
|
+
│
|
|
811
|
+
▼
|
|
812
|
+
┌──────────────────────────────┐
|
|
813
|
+
│ Repository │
|
|
814
|
+
│ SearchRepository │
|
|
815
|
+
│ CRUDRepository │
|
|
816
|
+
│ Repository │
|
|
817
|
+
└──────────────┬───────────────┘
|
|
818
|
+
│
|
|
819
|
+
▼
|
|
820
|
+
┌──────────────────────────────┐
|
|
821
|
+
│ Database Layer │
|
|
822
|
+
│ Executor │
|
|
823
|
+
│ Transaction │
|
|
824
|
+
│ DB │
|
|
825
|
+
└──────────────┬───────────────┘
|
|
826
|
+
│
|
|
827
|
+
▼
|
|
828
|
+
Database Driver
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
## Core Concepts
|
|
832
|
+
|
|
833
|
+
### Attribute metadata
|
|
834
|
+
|
|
835
|
+
`Attribute` describes how an application field should be mapped, validated, searched, formatted, or persisted.
|
|
836
|
+
|
|
837
|
+
```ts
|
|
838
|
+
const attributes: Attributes = {
|
|
839
|
+
id: {
|
|
840
|
+
field: 'id',
|
|
841
|
+
column: 'user_id',
|
|
842
|
+
type: 'ObjectId',
|
|
843
|
+
key: true,
|
|
844
|
+
},
|
|
845
|
+
|
|
846
|
+
email: {
|
|
847
|
+
field: 'email',
|
|
848
|
+
column: 'email_address',
|
|
849
|
+
type: 'string',
|
|
850
|
+
format: 'email',
|
|
851
|
+
required: true,
|
|
852
|
+
unique: true,
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
age: {
|
|
856
|
+
field: 'age',
|
|
857
|
+
type: 'integer',
|
|
858
|
+
min: 0,
|
|
859
|
+
max: 150,
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
Supported data types include:
|
|
865
|
+
|
|
866
|
+
```ts
|
|
867
|
+
type DataType =
|
|
868
|
+
| 'ObjectId'
|
|
869
|
+
| 'date'
|
|
870
|
+
| 'datetime'
|
|
871
|
+
| 'time'
|
|
872
|
+
| 'boolean'
|
|
873
|
+
| 'number'
|
|
874
|
+
| 'integer'
|
|
875
|
+
| 'string'
|
|
876
|
+
| 'text'
|
|
877
|
+
| 'object'
|
|
878
|
+
| 'array'
|
|
879
|
+
| 'binary'
|
|
880
|
+
| 'primitives'
|
|
881
|
+
| 'booleans'
|
|
882
|
+
| 'numbers'
|
|
883
|
+
| 'integers'
|
|
884
|
+
| 'strings'
|
|
885
|
+
| 'dates'
|
|
886
|
+
| 'datetimes'
|
|
887
|
+
| 'times';
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
Supported formats include:
|
|
891
|
+
|
|
892
|
+
```ts
|
|
893
|
+
type FormatType =
|
|
894
|
+
| 'currency'
|
|
895
|
+
| 'percentage'
|
|
896
|
+
| 'email'
|
|
897
|
+
| 'url'
|
|
898
|
+
| 'phone'
|
|
899
|
+
| 'fax'
|
|
900
|
+
| 'ipv4'
|
|
901
|
+
| 'ipv6';
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
## Database Executor
|
|
905
|
+
|
|
906
|
+
`Executor` is the low-level database abstraction.
|
|
907
|
+
|
|
908
|
+
```ts
|
|
909
|
+
export interface Executor {
|
|
910
|
+
driver: string;
|
|
911
|
+
|
|
912
|
+
param(i: number): string;
|
|
913
|
+
|
|
914
|
+
execute(
|
|
915
|
+
sql: string,
|
|
916
|
+
args?: any[]
|
|
917
|
+
): Promise<number>;
|
|
918
|
+
|
|
919
|
+
executeBatch(
|
|
920
|
+
statements: Statement[],
|
|
921
|
+
firstSuccess?: boolean
|
|
922
|
+
): Promise<number>;
|
|
923
|
+
|
|
924
|
+
query<T>(
|
|
925
|
+
sql: string,
|
|
926
|
+
args?: any[],
|
|
927
|
+
m?: StringMap,
|
|
928
|
+
bools?: Attribute[]
|
|
929
|
+
): Promise<T[]>;
|
|
930
|
+
}
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
The application can therefore depend on `Executor` rather than directly depending on a specific database driver.
|
|
934
|
+
|
|
935
|
+
### Statements
|
|
936
|
+
|
|
937
|
+
A batch statement is represented by:
|
|
938
|
+
|
|
939
|
+
```ts
|
|
940
|
+
export interface Statement {
|
|
941
|
+
query: string;
|
|
942
|
+
params?: any[];
|
|
943
|
+
}
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
Example:
|
|
947
|
+
|
|
948
|
+
```ts
|
|
949
|
+
const statements: Statement[] = [
|
|
950
|
+
{
|
|
951
|
+
query: 'UPDATE users SET active = ? WHERE id = ?',
|
|
952
|
+
params: [true, 1],
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
query: 'INSERT INTO audit_log(action) VALUES (?)',
|
|
956
|
+
params: ['activate-user'],
|
|
957
|
+
},
|
|
958
|
+
];
|
|
959
|
+
|
|
960
|
+
await executor.executeBatch(statements);
|
|
961
|
+
```
|
|
962
|
+
|
|
963
|
+
## Transactions
|
|
964
|
+
|
|
965
|
+
A transaction extends `Executor`:
|
|
966
|
+
|
|
967
|
+
```ts
|
|
968
|
+
export interface Transaction extends Executor {
|
|
969
|
+
commit(): Promise<void>;
|
|
970
|
+
rollback(): Promise<void>;
|
|
971
|
+
}
|
|
972
|
+
```
|
|
973
|
+
|
|
974
|
+
Transactions can be passed explicitly to repository operations.
|
|
975
|
+
|
|
976
|
+
```ts
|
|
977
|
+
const tx = await db.beginTransaction();
|
|
978
|
+
|
|
979
|
+
try {
|
|
980
|
+
await userRepository.create(user, tx);
|
|
981
|
+
await auditRepository.create(audit, tx);
|
|
982
|
+
|
|
983
|
+
await tx.commit();
|
|
984
|
+
} catch (error) {
|
|
985
|
+
await tx.rollback();
|
|
986
|
+
throw error;
|
|
987
|
+
}
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
### Transaction ownership
|
|
991
|
+
|
|
992
|
+
The recommended ownership model is:
|
|
993
|
+
|
|
994
|
+
> The caller that creates the transaction owns the transaction and is responsible for committing or rolling it back.
|
|
995
|
+
|
|
996
|
+
A repository should not commit or roll back a transaction supplied by its caller.
|
|
997
|
+
|
|
998
|
+
## Repository Layer
|
|
999
|
+
|
|
1000
|
+
### CRUD repository
|
|
1001
|
+
|
|
1002
|
+
```ts
|
|
1003
|
+
export interface CRUDRepository<T, ID> {
|
|
1004
|
+
load(id: ID, tx?: Transaction): Promise<T | null>;
|
|
1005
|
+
create(obj: T, tx?: Transaction): Promise<number>;
|
|
1006
|
+
update(obj: T, tx?: Transaction): Promise<number>;
|
|
1007
|
+
patch(obj: Partial<T>, tx?: Transaction): Promise<number>;
|
|
1008
|
+
delete(id: ID, tx?: Transaction): Promise<number>;
|
|
1009
|
+
}
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
Typical usage:
|
|
1013
|
+
|
|
1014
|
+
```ts
|
|
1015
|
+
const user = await repository.load(userId);
|
|
1016
|
+
|
|
1017
|
+
if (!user) {
|
|
1018
|
+
// not found
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
await repository.create(user);
|
|
1022
|
+
|
|
1023
|
+
await repository.update(user);
|
|
1024
|
+
|
|
1025
|
+
await repository.patch({
|
|
1026
|
+
id: user.id,
|
|
1027
|
+
name: 'Updated name',
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
await repository.delete(user.id);
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
The optional `Transaction` allows multiple repository operations to participate in the same transaction.
|
|
1034
|
+
|
|
1035
|
+
## Search Repository
|
|
1036
|
+
|
|
1037
|
+
Search is separated from CRUD:
|
|
1038
|
+
|
|
1039
|
+
```ts
|
|
1040
|
+
export interface SearchRepository<T, F extends Filter> {
|
|
1041
|
+
search(
|
|
1042
|
+
s: F,
|
|
1043
|
+
limit: number,
|
|
1044
|
+
offset?: number | string,
|
|
1045
|
+
fields?: string[],
|
|
1046
|
+
tx?: Transaction
|
|
1047
|
+
): Promise<SearchResult<T>>;
|
|
1048
|
+
}
|
|
1049
|
+
```
|
|
1050
|
+
|
|
1051
|
+
A typical filter contains:
|
|
1052
|
+
|
|
1053
|
+
```ts
|
|
1054
|
+
export interface Filter {
|
|
1055
|
+
page?: number;
|
|
1056
|
+
limit: number;
|
|
1057
|
+
fields?: string[];
|
|
1058
|
+
sort?: string;
|
|
1059
|
+
q?: string;
|
|
1060
|
+
}
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
Search results are represented by:
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
export interface SearchResult<T> {
|
|
1067
|
+
list: T[];
|
|
1068
|
+
total?: number;
|
|
1069
|
+
next?: string;
|
|
1070
|
+
}
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
Example:
|
|
1074
|
+
|
|
1075
|
+
```ts
|
|
1076
|
+
const result = await userRepository.search(
|
|
1077
|
+
{
|
|
1078
|
+
q: 'john',
|
|
1079
|
+
limit: 20,
|
|
1080
|
+
page: 1,
|
|
1081
|
+
sort: 'name',
|
|
1082
|
+
},
|
|
1083
|
+
20,
|
|
1084
|
+
);
|
|
1085
|
+
|
|
1086
|
+
console.log(result.list);
|
|
1087
|
+
console.log(result.total);
|
|
1088
|
+
console.log(result.next);
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
## Combined Repository
|
|
1092
|
+
|
|
1093
|
+
For entities that support both CRUD and search:
|
|
1094
|
+
|
|
1095
|
+
```ts
|
|
1096
|
+
export interface Repository<T, ID, F extends Filter>
|
|
1097
|
+
extends CRUDRepository<T, ID>,
|
|
1098
|
+
SearchRepository<T, F> {
|
|
1099
|
+
}
|
|
1100
|
+
```
|
|
1101
|
+
|
|
1102
|
+
Example:
|
|
1103
|
+
|
|
1104
|
+
```ts
|
|
1105
|
+
interface UserFilter extends Filter {
|
|
1106
|
+
active?: boolean;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
interface User {
|
|
1110
|
+
id: string;
|
|
1111
|
+
name: string;
|
|
1112
|
+
email: string;
|
|
1113
|
+
active: boolean;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
class UserRepository
|
|
1117
|
+
implements Repository<User, string, UserFilter> {
|
|
1118
|
+
|
|
1119
|
+
// implementation
|
|
1120
|
+
}
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
## Service / Use Case Layer
|
|
1124
|
+
|
|
1125
|
+
The service layer exposes repository operations without exposing transactions or database details to the caller.
|
|
1126
|
+
|
|
1127
|
+
### CRUD service
|
|
1128
|
+
|
|
1129
|
+
```ts
|
|
1130
|
+
export interface CRUDService<T, ID> {
|
|
1131
|
+
load(id: ID): Promise<T | null>;
|
|
1132
|
+
create(obj: T): Promise<Result<T>>;
|
|
1133
|
+
update(obj: T): Promise<Result<T>>;
|
|
1134
|
+
patch(obj: Partial<T>): Promise<Result<T>>;
|
|
1135
|
+
delete(id: ID): Promise<number>;
|
|
1136
|
+
}
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
### Search service
|
|
1140
|
+
|
|
1141
|
+
```ts
|
|
1142
|
+
export interface SearchService<T, F extends Filter> {
|
|
1143
|
+
search(
|
|
1144
|
+
s: F,
|
|
1145
|
+
limit: number,
|
|
1146
|
+
page?: number | string,
|
|
1147
|
+
fields?: string[]
|
|
1148
|
+
): Promise<SearchResult<T>>;
|
|
1149
|
+
}
|
|
1150
|
+
```
|
|
1151
|
+
|
|
1152
|
+
### Combined service
|
|
1153
|
+
|
|
1154
|
+
```ts
|
|
1155
|
+
export interface Service<T, ID, F extends Filter>
|
|
1156
|
+
extends CRUDService<T, ID>,
|
|
1157
|
+
SearchService<T, F> {
|
|
1158
|
+
}
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
## Use Case Implementations
|
|
1162
|
+
|
|
1163
|
+
`SearchUseCase` delegates search operations:
|
|
1164
|
+
|
|
1165
|
+
```ts
|
|
1166
|
+
const useCase = new SearchUseCase(userRepository);
|
|
1167
|
+
|
|
1168
|
+
const result = await useCase.search(
|
|
1169
|
+
{
|
|
1170
|
+
q: 'john',
|
|
1171
|
+
limit: 20,
|
|
1172
|
+
},
|
|
1173
|
+
20,
|
|
1174
|
+
);
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
`CRUDUseCase` delegates CRUD operations:
|
|
1178
|
+
|
|
1179
|
+
```ts
|
|
1180
|
+
const useCase = new CRUDUseCase(userRepository);
|
|
1181
|
+
|
|
1182
|
+
const user = await useCase.load(id);
|
|
1183
|
+
|
|
1184
|
+
await useCase.create(userData);
|
|
1185
|
+
await useCase.update(userData);
|
|
1186
|
+
await useCase.patch({ id, name: 'New name' });
|
|
1187
|
+
await useCase.delete(id);
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
`UseCase` combines both APIs:
|
|
1191
|
+
|
|
1192
|
+
```ts
|
|
1193
|
+
const useCase = new UseCase(userRepository);
|
|
1194
|
+
|
|
1195
|
+
const result = await useCase.search(
|
|
1196
|
+
{
|
|
1197
|
+
q: 'john',
|
|
1198
|
+
limit: 20,
|
|
1199
|
+
},
|
|
1200
|
+
20,
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
const user = await useCase.load(id);
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
These classes are intentionally thin. They provide an application-facing boundary where business rules can be added without coupling controllers or handlers directly to repositories.
|
|
1207
|
+
|
|
1208
|
+
## Error Model
|
|
1209
|
+
|
|
1210
|
+
Validation/application errors are represented by:
|
|
1211
|
+
|
|
1212
|
+
```ts
|
|
1213
|
+
export interface ErrorMessage {
|
|
1214
|
+
field: string;
|
|
1215
|
+
code: string;
|
|
1216
|
+
param?: string | number | Date;
|
|
1217
|
+
message?: string;
|
|
1218
|
+
}
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
The current result type is:
|
|
1222
|
+
|
|
1223
|
+
```ts
|
|
1224
|
+
export type Result<T> =
|
|
1225
|
+
| number
|
|
1226
|
+
| T
|
|
1227
|
+
| ErrorMessage[];
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
For example, an implementation may return:
|
|
1231
|
+
|
|
1232
|
+
```ts
|
|
1233
|
+
[
|
|
1234
|
+
{
|
|
1235
|
+
field: 'email',
|
|
1236
|
+
code: 'invalid',
|
|
1237
|
+
message: 'Invalid email address',
|
|
1238
|
+
},
|
|
1239
|
+
]
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
For larger applications, a discriminated result type is recommended because it makes success and failure states easier to distinguish.
|
|
1243
|
+
|
|
1244
|
+
## Security Considerations
|
|
1245
|
+
|
|
1246
|
+
### Always parameterize values
|
|
1247
|
+
|
|
1248
|
+
Do not construct SQL by interpolating user-provided values.
|
|
1249
|
+
|
|
1250
|
+
Prefer:
|
|
1251
|
+
|
|
1252
|
+
```ts
|
|
1253
|
+
await executor.execute(
|
|
1254
|
+
'UPDATE users SET name = ? WHERE id = ?',
|
|
1255
|
+
[name, id],
|
|
1256
|
+
);
|
|
1257
|
+
```
|
|
1258
|
+
|
|
1259
|
+
Avoid:
|
|
1260
|
+
|
|
1261
|
+
```ts
|
|
1262
|
+
await executor.execute(
|
|
1263
|
+
`UPDATE users SET name = '${name}' WHERE id = '${id}'`,
|
|
1264
|
+
);
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
### Validate SQL identifiers
|
|
1268
|
+
|
|
1269
|
+
Fields, columns, and sort expressions are different from ordinary values.
|
|
1270
|
+
|
|
1271
|
+
For example:
|
|
1272
|
+
|
|
1273
|
+
```ts
|
|
1274
|
+
filter.sort
|
|
1275
|
+
filter.fields
|
|
1276
|
+
attribute.column
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
should not be inserted into SQL without validation or an allowlist.
|
|
1280
|
+
|
|
1281
|
+
Prefer mapping application-level names to trusted database columns:
|
|
1282
|
+
|
|
1283
|
+
```ts
|
|
1284
|
+
const columns = {
|
|
1285
|
+
id: 'user_id',
|
|
1286
|
+
name: 'user_name',
|
|
1287
|
+
createdAt: 'created_at',
|
|
1288
|
+
};
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
Never treat an arbitrary `sort` or `fields` value as trusted SQL.
|
|
1292
|
+
|
|
1293
|
+
## Design Guidelines
|
|
1294
|
+
|
|
1295
|
+
### Repository responsibilities
|
|
1296
|
+
|
|
1297
|
+
Repositories should focus on:
|
|
1298
|
+
|
|
1299
|
+
* persistence
|
|
1300
|
+
* SQL generation/execution
|
|
1301
|
+
* mapping database rows to application objects
|
|
1302
|
+
* transaction participation
|
|
1303
|
+
|
|
1304
|
+
They should not normally contain HTTP concerns or presentation logic.
|
|
1305
|
+
|
|
1306
|
+
### Use case responsibilities
|
|
1307
|
+
|
|
1308
|
+
Use cases are the preferred location for:
|
|
1309
|
+
|
|
1310
|
+
* business rules
|
|
1311
|
+
* orchestration across multiple repositories
|
|
1312
|
+
* authorization decisions
|
|
1313
|
+
* transaction coordination
|
|
1314
|
+
* application-level validation
|
|
1315
|
+
|
|
1316
|
+
### Transaction coordination
|
|
1317
|
+
|
|
1318
|
+
For operations spanning multiple repositories:
|
|
1319
|
+
|
|
1320
|
+
```ts
|
|
1321
|
+
const tx = await db.beginTransaction();
|
|
1322
|
+
|
|
1323
|
+
try {
|
|
1324
|
+
await repositoryA.update(a, tx);
|
|
1325
|
+
await repositoryB.create(b, tx);
|
|
1326
|
+
|
|
1327
|
+
await tx.commit();
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
await tx.rollback();
|
|
1330
|
+
throw error;
|
|
1331
|
+
}
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
This keeps transaction lifecycle management outside individual repositories.
|
|
1335
|
+
|
|
1336
|
+
## Known API Considerations
|
|
1337
|
+
|
|
1338
|
+
The current API has several areas that should be kept in mind when extending the library.
|
|
1339
|
+
|
|
1340
|
+
### Filter and search pagination
|
|
1341
|
+
|
|
1342
|
+
`Filter` already contains:
|
|
1343
|
+
|
|
1344
|
+
```ts
|
|
1345
|
+
page
|
|
1346
|
+
limit
|
|
1347
|
+
fields
|
|
1348
|
+
```
|
|
1349
|
+
|
|
1350
|
+
while `SearchRepository.search()` also accepts:
|
|
1351
|
+
|
|
1352
|
+
```ts
|
|
1353
|
+
limit
|
|
1354
|
+
offset
|
|
1355
|
+
fields
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
These represent overlapping concepts. A future API revision should establish a single source of truth for pagination and selected fields.
|
|
1359
|
+
|
|
1360
|
+
### Attribute responsibilities
|
|
1361
|
+
|
|
1362
|
+
`Attribute` currently combines:
|
|
1363
|
+
|
|
1364
|
+
* database mapping
|
|
1365
|
+
* validation
|
|
1366
|
+
* formatting
|
|
1367
|
+
* search metadata
|
|
1368
|
+
* CRUD restrictions
|
|
1369
|
+
* serialization
|
|
1370
|
+
* relationship metadata
|
|
1371
|
+
|
|
1372
|
+
This is convenient for a small framework but may become difficult to maintain as the number of features grows.
|
|
1373
|
+
|
|
1374
|
+
### Result type
|
|
1375
|
+
|
|
1376
|
+
```ts
|
|
1377
|
+
number | T | ErrorMessage[]
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
is intentionally flexible but can be difficult for callers to discriminate safely. A future version may use discriminated result objects.
|
|
1381
|
+
|
|
1382
|
+
### Patch behavior
|
|
1383
|
+
|
|
1384
|
+
`patch()` uses `Partial<T>`. This is convenient, but it does not express rules such as:
|
|
1385
|
+
|
|
1386
|
+
* immutable fields
|
|
1387
|
+
* required primary keys
|
|
1388
|
+
* fields that cannot be patched
|
|
1389
|
+
* fields that are read-only
|
|
1390
|
+
|
|
1391
|
+
Those rules currently need to be enforced through metadata or repository logic.
|
|
1392
|
+
|
|
1393
|
+
## Recommended Improvements
|
|
1394
|
+
|
|
1395
|
+
For future versions, consider:
|
|
1396
|
+
|
|
1397
|
+
1. Use `unknown[]` instead of `any[]` for SQL parameters.
|
|
1398
|
+
2. Define explicit search pagination semantics.
|
|
1399
|
+
3. Replace free-form `sort` strings with validated sort definitions.
|
|
1400
|
+
4. Separate validation, persistence, search, and formatting metadata from `Attribute`.
|
|
1401
|
+
5. Replace `Result<T>` with a discriminated result type.
|
|
1402
|
+
6. Remove defensive checks for methods that are already required by TypeScript interfaces.
|
|
1403
|
+
7. Clearly document transaction ownership.
|
|
1404
|
+
8. Consider separate types for database entities, create inputs, update inputs, and patch inputs.
|
|
1405
|
+
9. Export `StringMap` if it is intended to be part of the public API.
|
|
1406
|
+
10. Use descriptive parameter/property names instead of ambiguous names such as `bools`, `true`, `false`, and `typeof`.
|
|
1407
|
+
|
|
1408
|
+
## Summary
|
|
1409
|
+
|
|
1410
|
+
This library provides a clean foundation for a TypeScript application using:
|
|
1411
|
+
|
|
1412
|
+
```text
|
|
1413
|
+
Database
|
|
1414
|
+
↓
|
|
1415
|
+
Executor
|
|
1416
|
+
↓
|
|
1417
|
+
Transaction / Repository
|
|
1418
|
+
↓
|
|
1419
|
+
Use Case / Service
|
|
1420
|
+
↓
|
|
1421
|
+
Application
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
Its main strengths are its small API surface, generic repository interfaces, explicit transaction support, and separation between persistence and application layers.
|
|
1425
|
+
|
|
1426
|
+
The most important areas to control as the framework evolves are SQL identifier safety, search/pagination consistency, transaction ownership, the overloaded `Attribute` metadata model, and the `Result<T>` error contract.
|
|
1427
|
+
|
|
1428
|
+
MIT
|
|
1429
|
+
|
|
1430
|
+
# TypeScript Application Core
|
|
1431
|
+
|
|
1432
|
+
A set of TypeScript interfaces, types, and lightweight use-case implementations for building applications around repositories, databases, validation, caching, logging, and messaging.
|
|
1433
|
+
|
|
1434
|
+
The library is designed around **interfaces and dependency inversion**, allowing application code to depend on abstractions rather than concrete infrastructure implementations.
|
|
1435
|
+
|
|
1436
|
+
## Features
|
|
1437
|
+
|
|
1438
|
+
* Generic CRUD and search repositories
|
|
1439
|
+
* Database executor and transaction abstractions
|
|
1440
|
+
* Application service/use-case contracts
|
|
1441
|
+
* Validation contracts and structured validation errors
|
|
1442
|
+
* Cache port abstraction
|
|
1443
|
+
* Structured logger abstraction
|
|
1444
|
+
* Generic messaging and message-envelope types
|
|
1445
|
+
* Shared schema and attribute metadata
|
|
1446
|
+
* Strong generic typing for entities, IDs, filters, and messages
|
|
1447
|
+
|
|
1448
|
+
## Architecture
|
|
1449
|
+
|
|
1450
|
+
The main dependency flow is:
|
|
1451
|
+
|
|
1452
|
+
```text
|
|
1453
|
+
Application
|
|
1454
|
+
│
|
|
1455
|
+
▼
|
|
1456
|
+
UseCase / Service
|
|
1457
|
+
│
|
|
1458
|
+
▼
|
|
1459
|
+
Repository
|
|
1460
|
+
│
|
|
1461
|
+
▼
|
|
1462
|
+
Executor
|
|
1463
|
+
│
|
|
1464
|
+
├── DB
|
|
1465
|
+
└── Transaction
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
Infrastructure services are exposed through ports:
|
|
1469
|
+
|
|
1470
|
+
```text
|
|
1471
|
+
Application
|
|
1472
|
+
├── CachePort
|
|
1473
|
+
├── Logger
|
|
1474
|
+
└── Messaging ports
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
This keeps business/application code independent from concrete database, cache, logging, and messaging implementations.
|
|
1478
|
+
|
|
1479
|
+
---
|
|
1480
|
+
|
|
1481
|
+
# Core Types
|
|
1482
|
+
|
|
1483
|
+
## Data types
|
|
1484
|
+
|
|
1485
|
+
`DataType` describes supported field types:
|
|
1486
|
+
|
|
1487
|
+
```ts
|
|
1488
|
+
type DataType =
|
|
1489
|
+
| 'ObjectId'
|
|
1490
|
+
| 'date'
|
|
1491
|
+
| 'datetime'
|
|
1492
|
+
| 'time'
|
|
1493
|
+
| 'boolean'
|
|
1494
|
+
| 'number'
|
|
1495
|
+
| 'integer'
|
|
1496
|
+
| 'string'
|
|
1497
|
+
| 'text'
|
|
1498
|
+
| 'object'
|
|
1499
|
+
| 'array'
|
|
1500
|
+
| 'binary'
|
|
1501
|
+
| 'primitives'
|
|
1502
|
+
| 'booleans'
|
|
1503
|
+
| 'numbers'
|
|
1504
|
+
| 'integers'
|
|
1505
|
+
| 'strings'
|
|
1506
|
+
| 'dates'
|
|
1507
|
+
| 'datetimes'
|
|
1508
|
+
| 'times';
|
|
1509
|
+
```
|
|
1510
|
+
|
|
1511
|
+
`FormatType` provides common semantic formats:
|
|
1512
|
+
|
|
1513
|
+
```ts
|
|
1514
|
+
type FormatType =
|
|
1515
|
+
| 'currency'
|
|
1516
|
+
| 'percentage'
|
|
1517
|
+
| 'email'
|
|
1518
|
+
| 'url'
|
|
1519
|
+
| 'phone'
|
|
1520
|
+
| 'fax'
|
|
1521
|
+
| 'ipv4'
|
|
1522
|
+
| 'ipv6';
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
`Operator` defines supported query operations:
|
|
1526
|
+
|
|
1527
|
+
```ts
|
|
1528
|
+
type Operator =
|
|
1529
|
+
| '='
|
|
1530
|
+
| 'like'
|
|
1531
|
+
| '!='
|
|
1532
|
+
| '<>'
|
|
1533
|
+
| '>'
|
|
1534
|
+
| '>='
|
|
1535
|
+
| '<'
|
|
1536
|
+
| '<=';
|
|
1537
|
+
```
|
|
1538
|
+
|
|
1539
|
+
## Attribute metadata
|
|
1540
|
+
|
|
1541
|
+
`Attribute` describes a model field and can contain database, validation, search, formatting, and persistence metadata.
|
|
1542
|
+
|
|
1543
|
+
```ts
|
|
1544
|
+
const attributes: Attributes = {
|
|
1545
|
+
email: {
|
|
1546
|
+
field: 'email',
|
|
1547
|
+
type: 'string',
|
|
1548
|
+
format: 'email',
|
|
1549
|
+
required: true,
|
|
1550
|
+
unique: true
|
|
1551
|
+
},
|
|
1552
|
+
|
|
1553
|
+
age: {
|
|
1554
|
+
field: 'age',
|
|
1555
|
+
type: 'integer',
|
|
1556
|
+
min: 0
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
```
|
|
1560
|
+
|
|
1561
|
+
Common attributes include:
|
|
1562
|
+
|
|
1563
|
+
| Property | Purpose |
|
|
1564
|
+
| ------------------------- | ----------------------------- |
|
|
1565
|
+
| `field` | Application field name |
|
|
1566
|
+
| `column` | Database column |
|
|
1567
|
+
| `type` | Field data type |
|
|
1568
|
+
| `format` | Semantic/display format |
|
|
1569
|
+
| `required` | Required-field validation |
|
|
1570
|
+
| `operator` | Default query operator |
|
|
1571
|
+
| `enum` | Allowed values |
|
|
1572
|
+
| `min` / `max` | Range validation |
|
|
1573
|
+
| `gt` / `lt` | Exclusive range validation |
|
|
1574
|
+
| `length` | Length constraint |
|
|
1575
|
+
| `precision` / `scale` | Numeric precision |
|
|
1576
|
+
| `exp` | Regular-expression validation |
|
|
1577
|
+
| `key` | Primary/key metadata |
|
|
1578
|
+
| `unique` | Uniqueness metadata |
|
|
1579
|
+
| `version` | Version field |
|
|
1580
|
+
| `createdAt` / `updatedAt` | Audit timestamps |
|
|
1581
|
+
| `noinsert` | Exclude from insert |
|
|
1582
|
+
| `noupdate` | Exclude from update |
|
|
1583
|
+
| `nopatch` | Exclude from patch |
|
|
1584
|
+
| `ignored` | Ignore the field |
|
|
1585
|
+
| `jsonField` | JSON field mapping |
|
|
1586
|
+
| `link` | Related-resource metadata |
|
|
1587
|
+
| `getString` | Custom string conversion |
|
|
1588
|
+
|
|
1589
|
+
---
|
|
1590
|
+
|
|
1591
|
+
# Filtering and Search
|
|
1592
|
+
|
|
1593
|
+
`Filter` represents a generic search filter:
|
|
1594
|
+
|
|
1595
|
+
```ts
|
|
1596
|
+
interface Filter {
|
|
1597
|
+
page?: number;
|
|
1598
|
+
limit: number;
|
|
1599
|
+
fields?: string[];
|
|
1600
|
+
sort?: string;
|
|
1601
|
+
q?: string;
|
|
1602
|
+
}
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
A search returns:
|
|
1606
|
+
|
|
1607
|
+
```ts
|
|
1608
|
+
interface SearchResult<T> {
|
|
1609
|
+
list: T[];
|
|
1610
|
+
total?: number;
|
|
1611
|
+
next?: string;
|
|
1612
|
+
}
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
Example:
|
|
1616
|
+
|
|
1617
|
+
```ts
|
|
1618
|
+
interface UserFilter extends Filter {
|
|
1619
|
+
status?: string;
|
|
1620
|
+
role?: string;
|
|
1621
|
+
}
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
Repositories can then provide strongly typed search:
|
|
1625
|
+
|
|
1626
|
+
```ts
|
|
1627
|
+
interface UserRepository
|
|
1628
|
+
extends Repository<User, string, UserFilter> {}
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
---
|
|
1632
|
+
|
|
1633
|
+
# Database Abstractions
|
|
1634
|
+
|
|
1635
|
+
## Executor
|
|
1636
|
+
|
|
1637
|
+
`Executor` abstracts database operations:
|
|
1638
|
+
|
|
1639
|
+
```ts
|
|
1640
|
+
interface Executor {
|
|
1641
|
+
driver: string;
|
|
1642
|
+
|
|
1643
|
+
param(i: number): string;
|
|
1644
|
+
|
|
1645
|
+
execute(
|
|
1646
|
+
sql: string,
|
|
1647
|
+
args?: any[]
|
|
1648
|
+
): Promise<number>;
|
|
1649
|
+
|
|
1650
|
+
executeBatch(
|
|
1651
|
+
statements: Statement[],
|
|
1652
|
+
firstSuccess?: boolean
|
|
1653
|
+
): Promise<number>;
|
|
1654
|
+
|
|
1655
|
+
query<T>(
|
|
1656
|
+
sql: string,
|
|
1657
|
+
args?: any[],
|
|
1658
|
+
m?: StringMap,
|
|
1659
|
+
bools?: Attribute[]
|
|
1660
|
+
): Promise<T[]>;
|
|
1661
|
+
}
|
|
1662
|
+
```
|
|
1663
|
+
|
|
1664
|
+
A repository does not need to know which database driver is being used.
|
|
1665
|
+
|
|
1666
|
+
## Transaction
|
|
1667
|
+
|
|
1668
|
+
`Transaction` extends `Executor`:
|
|
1669
|
+
|
|
1670
|
+
```ts
|
|
1671
|
+
interface Transaction extends Executor {
|
|
1672
|
+
commit(): Promise<void>;
|
|
1673
|
+
rollback(): Promise<void>;
|
|
1674
|
+
}
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
Typical usage:
|
|
1678
|
+
|
|
1679
|
+
```ts
|
|
1680
|
+
const tx = await db.beginTransaction();
|
|
1681
|
+
|
|
1682
|
+
try {
|
|
1683
|
+
await tx.execute(...);
|
|
1684
|
+
await tx.execute(...);
|
|
1685
|
+
|
|
1686
|
+
await tx.commit();
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
await tx.rollback();
|
|
1689
|
+
throw error;
|
|
1690
|
+
}
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
Transaction state management remains the responsibility of the concrete implementation.
|
|
1694
|
+
|
|
1695
|
+
## DB
|
|
1696
|
+
|
|
1697
|
+
```ts
|
|
1698
|
+
interface DB extends Executor {
|
|
1699
|
+
beginTransaction(): Promise<Transaction>;
|
|
1700
|
+
}
|
|
1701
|
+
```
|
|
1702
|
+
|
|
1703
|
+
A concrete database adapter can implement `DB` while repositories depend only on the interface.
|
|
1704
|
+
|
|
1705
|
+
---
|
|
1706
|
+
|
|
1707
|
+
# Repositories
|
|
1708
|
+
|
|
1709
|
+
The repository layer separates persistence operations from application logic.
|
|
1710
|
+
|
|
1711
|
+
## CRUD repository
|
|
1712
|
+
|
|
1713
|
+
```ts
|
|
1714
|
+
interface CRUDRepository<T, ID> {
|
|
1715
|
+
load(id: ID, tx?: Transaction): Promise<T | null>;
|
|
1716
|
+
create(obj: T, tx?: Transaction): Promise<number>;
|
|
1717
|
+
update(obj: T, tx?: Transaction): Promise<number>;
|
|
1718
|
+
patch(obj: Partial<T>, tx?: Transaction): Promise<number>;
|
|
1719
|
+
delete(id: ID, tx?: Transaction): Promise<number>;
|
|
1720
|
+
}
|
|
1721
|
+
```
|
|
1722
|
+
|
|
1723
|
+
## Search repository
|
|
1724
|
+
|
|
1725
|
+
```ts
|
|
1726
|
+
interface SearchRepository<T, F extends Filter> {
|
|
1727
|
+
search(
|
|
1728
|
+
s: F,
|
|
1729
|
+
limit: number,
|
|
1730
|
+
offset?: number | string,
|
|
1731
|
+
fields?: string[],
|
|
1732
|
+
tx?: Transaction
|
|
1733
|
+
): Promise<SearchResult<T>>;
|
|
1734
|
+
}
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1737
|
+
## Combined repository
|
|
1738
|
+
|
|
1739
|
+
```ts
|
|
1740
|
+
interface Repository<T, ID, F extends Filter>
|
|
1741
|
+
extends CRUDRepository<T, ID>,
|
|
1742
|
+
SearchRepository<T, F> {}
|
|
1743
|
+
```
|
|
1744
|
+
|
|
1745
|
+
This allows CRUD and search capabilities to be combined without forcing unrelated implementations into one interface.
|
|
1746
|
+
|
|
1747
|
+
---
|
|
1748
|
+
|
|
1749
|
+
# Use Cases and Services
|
|
1750
|
+
|
|
1751
|
+
The library provides thin application-layer implementations.
|
|
1752
|
+
|
|
1753
|
+
## SearchUseCase
|
|
1754
|
+
|
|
1755
|
+
```ts
|
|
1756
|
+
class SearchUseCase<T, F extends Filter>
|
|
1757
|
+
implements SearchService<T, F> {
|
|
1758
|
+
|
|
1759
|
+
constructor(
|
|
1760
|
+
protected repository: SearchRepository<T, F>
|
|
1761
|
+
) {}
|
|
1762
|
+
|
|
1763
|
+
search(
|
|
1764
|
+
filter: F,
|
|
1765
|
+
limit: number,
|
|
1766
|
+
page?: number | string,
|
|
1767
|
+
fields?: string[]
|
|
1768
|
+
): Promise<SearchResult<T>> {
|
|
1769
|
+
return this.repository.search(
|
|
1770
|
+
filter,
|
|
1771
|
+
limit,
|
|
1772
|
+
page,
|
|
1773
|
+
fields
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
```
|
|
1778
|
+
|
|
1779
|
+
## CRUDUseCase
|
|
1780
|
+
|
|
1781
|
+
```ts
|
|
1782
|
+
class CRUDUseCase<T, ID>
|
|
1783
|
+
implements CRUDService<T, ID> {
|
|
1784
|
+
|
|
1785
|
+
constructor(
|
|
1786
|
+
protected repository: CRUDRepository<T, ID>
|
|
1787
|
+
) {}
|
|
1788
|
+
|
|
1789
|
+
load(id: ID) {
|
|
1790
|
+
return this.repository.load(id);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
create(obj: T) {
|
|
1794
|
+
return this.repository.create(obj);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
update(obj: T) {
|
|
1798
|
+
return this.repository.update(obj);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
patch(obj: Partial<T>) {
|
|
1802
|
+
return this.repository.patch(obj);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
delete(id: ID) {
|
|
1806
|
+
return this.repository.delete(id);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1811
|
+
## Combined UseCase
|
|
1812
|
+
|
|
1813
|
+
```ts
|
|
1814
|
+
class UseCase<T, ID, F extends Filter>
|
|
1815
|
+
implements Service<T, ID, F> {
|
|
1816
|
+
|
|
1817
|
+
constructor(
|
|
1818
|
+
protected repository: Repository<T, ID, F>
|
|
1819
|
+
) {}
|
|
1820
|
+
|
|
1821
|
+
// CRUD + search operations
|
|
1822
|
+
}
|
|
1823
|
+
```
|
|
1824
|
+
|
|
1825
|
+
These classes are intentionally thin. Business rules can be added at the use-case layer without coupling them to database implementations.
|
|
1826
|
+
|
|
1827
|
+
---
|
|
1828
|
+
|
|
1829
|
+
# Services
|
|
1830
|
+
|
|
1831
|
+
The service contracts provide an application-facing API:
|
|
1832
|
+
|
|
1833
|
+
```ts
|
|
1834
|
+
interface SearchService<T, F extends Filter> {
|
|
1835
|
+
search(
|
|
1836
|
+
s: F,
|
|
1837
|
+
limit: number,
|
|
1838
|
+
page?: number | string,
|
|
1839
|
+
fields?: string[]
|
|
1840
|
+
): Promise<SearchResult<T>>;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
interface CRUDService<T, ID> {
|
|
1844
|
+
load(id: ID): Promise<T | null>;
|
|
1845
|
+
create(obj: T): Promise<Result<T>>;
|
|
1846
|
+
update(obj: T): Promise<Result<T>>;
|
|
1847
|
+
patch(obj: Partial<T>): Promise<Result<T>>;
|
|
1848
|
+
delete(id: ID): Promise<number>;
|
|
1849
|
+
}
|
|
1850
|
+
```
|
|
1851
|
+
|
|
1852
|
+
The combined service is:
|
|
1853
|
+
|
|
1854
|
+
```ts
|
|
1855
|
+
interface Service<T, ID, F extends Filter>
|
|
1856
|
+
extends CRUDService<T, ID>,
|
|
1857
|
+
SearchService<T, F> {}
|
|
1858
|
+
```
|
|
1859
|
+
|
|
1860
|
+
---
|
|
1861
|
+
|
|
1862
|
+
# Validation
|
|
1863
|
+
|
|
1864
|
+
Validation is abstracted behind:
|
|
1865
|
+
|
|
1866
|
+
```ts
|
|
1867
|
+
interface Validator<T> {
|
|
1868
|
+
validate(
|
|
1869
|
+
model: T,
|
|
1870
|
+
resource?: StringMap,
|
|
1871
|
+
patch?: boolean
|
|
1872
|
+
): Promise<ErrorMessage[]>;
|
|
1873
|
+
}
|
|
1874
|
+
```
|
|
1875
|
+
|
|
1876
|
+
Validation errors use:
|
|
1877
|
+
|
|
1878
|
+
```ts
|
|
1879
|
+
interface ErrorMessage {
|
|
1880
|
+
field: string;
|
|
1881
|
+
code: string;
|
|
1882
|
+
param?: string | number | Date;
|
|
1883
|
+
message?: string;
|
|
1884
|
+
}
|
|
1885
|
+
```
|
|
1886
|
+
|
|
1887
|
+
Example:
|
|
1888
|
+
|
|
1889
|
+
```ts
|
|
1890
|
+
[
|
|
1891
|
+
{
|
|
1892
|
+
field: 'email',
|
|
1893
|
+
code: 'format',
|
|
1894
|
+
message: 'Invalid email address'
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
field: 'age',
|
|
1898
|
+
code: 'min',
|
|
1899
|
+
param: 18,
|
|
1900
|
+
message: 'Age must be at least 18'
|
|
1901
|
+
}
|
|
1902
|
+
]
|
|
1903
|
+
```
|
|
1904
|
+
|
|
1905
|
+
---
|
|
1906
|
+
|
|
1907
|
+
# Result Type
|
|
1908
|
+
|
|
1909
|
+
Service methods use:
|
|
1910
|
+
|
|
1911
|
+
```ts
|
|
1912
|
+
type Result<T> =
|
|
1913
|
+
| number
|
|
1914
|
+
| T
|
|
1915
|
+
| ErrorMessage[];
|
|
1916
|
+
```
|
|
1917
|
+
|
|
1918
|
+
This allows a service to represent an operation result, returned model, or validation errors.
|
|
1919
|
+
|
|
1920
|
+
Because this is a broad union, consumers should define clear runtime handling conventions. A discriminated result type is preferable for new APIs where backwards compatibility is not required.
|
|
1921
|
+
|
|
1922
|
+
---
|
|
1923
|
+
|
|
1924
|
+
# Caching
|
|
1925
|
+
|
|
1926
|
+
`CachePort<K, V>` provides a database-independent cache abstraction:
|
|
1927
|
+
|
|
1928
|
+
```ts
|
|
1929
|
+
interface CachePort<K, V> {
|
|
1930
|
+
isEnabled?(): boolean;
|
|
1931
|
+
|
|
1932
|
+
put(
|
|
1933
|
+
key: K,
|
|
1934
|
+
obj: V,
|
|
1935
|
+
expiresInSeconds?: number
|
|
1936
|
+
): Promise<boolean>;
|
|
1937
|
+
|
|
1938
|
+
expire(
|
|
1939
|
+
key: K,
|
|
1940
|
+
timeToLive: number
|
|
1941
|
+
): Promise<boolean>;
|
|
1942
|
+
|
|
1943
|
+
get(key: K): Promise<V>;
|
|
1944
|
+
|
|
1945
|
+
getMany(keys: K[]): Promise<V[]>;
|
|
1946
|
+
|
|
1947
|
+
containsKey(key: K): Promise<boolean>;
|
|
1948
|
+
|
|
1949
|
+
remove(key: K): Promise<boolean>;
|
|
1950
|
+
|
|
1951
|
+
clear(): Promise<boolean>;
|
|
1952
|
+
|
|
1953
|
+
keys(): Promise<string[]>;
|
|
1954
|
+
|
|
1955
|
+
count(): Promise<number>;
|
|
1956
|
+
|
|
1957
|
+
size(): Promise<number>;
|
|
1958
|
+
}
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
This allows implementations for Redis, in-memory caches, distributed caches, or other storage systems without changing application code.
|
|
1962
|
+
|
|
1963
|
+
---
|
|
1964
|
+
|
|
1965
|
+
# Logging
|
|
1966
|
+
|
|
1967
|
+
The logging layer consists of configuration types and a `Logger` interface.
|
|
1968
|
+
|
|
1969
|
+
## Logger
|
|
1970
|
+
|
|
1971
|
+
```ts
|
|
1972
|
+
interface Logger {
|
|
1973
|
+
name: Name;
|
|
1974
|
+
level: number;
|
|
1975
|
+
map: LogMap;
|
|
1976
|
+
constants?: SimpleMap;
|
|
1977
|
+
|
|
1978
|
+
trace(
|
|
1979
|
+
msg: string,
|
|
1980
|
+
m?: SimpleMap,
|
|
1981
|
+
ctx?: any
|
|
1982
|
+
): void;
|
|
1983
|
+
|
|
1984
|
+
debug(
|
|
1985
|
+
msg: string,
|
|
1986
|
+
m?: SimpleMap,
|
|
1987
|
+
ctx?: any
|
|
1988
|
+
): void;
|
|
1989
|
+
|
|
1990
|
+
info(
|
|
1991
|
+
msg: string,
|
|
1992
|
+
m?: SimpleMap,
|
|
1993
|
+
ctx?: any
|
|
1994
|
+
): void;
|
|
1995
|
+
|
|
1996
|
+
warn(
|
|
1997
|
+
msg: string,
|
|
1998
|
+
m?: SimpleMap,
|
|
1999
|
+
ctx?: any
|
|
2000
|
+
): void;
|
|
2001
|
+
|
|
2002
|
+
error(
|
|
2003
|
+
msg: string,
|
|
2004
|
+
m?: SimpleMap,
|
|
2005
|
+
ctx?: any
|
|
2006
|
+
): void;
|
|
2007
|
+
|
|
2008
|
+
panic(
|
|
2009
|
+
msg: string,
|
|
2010
|
+
m?: SimpleMap,
|
|
2011
|
+
ctx?: any
|
|
2012
|
+
): void;
|
|
2013
|
+
|
|
2014
|
+
fatal(
|
|
2015
|
+
msg: string,
|
|
2016
|
+
m?: SimpleMap,
|
|
2017
|
+
ctx?: any
|
|
2018
|
+
): void;
|
|
2019
|
+
|
|
2020
|
+
isLevelEnabled(level: number): boolean;
|
|
2021
|
+
isTraceEnabled(): boolean;
|
|
2022
|
+
isDebugEnabled(): boolean;
|
|
2023
|
+
isInfoEnabled(): boolean;
|
|
2024
|
+
isWarnEnabled(): boolean;
|
|
2025
|
+
isErrorEnabled(): boolean;
|
|
2026
|
+
isPanicEnabled(): boolean;
|
|
2027
|
+
isFatalEnabled(): boolean;
|
|
2028
|
+
}
|
|
2029
|
+
```
|
|
2030
|
+
|
|
2031
|
+
Logging configuration supports level names, field mappings, and constant metadata.
|
|
2032
|
+
|
|
2033
|
+
Example:
|
|
2034
|
+
|
|
2035
|
+
```ts
|
|
2036
|
+
const config: LogConfig = {
|
|
2037
|
+
log: {
|
|
2038
|
+
level: 'info',
|
|
2039
|
+
map: {
|
|
2040
|
+
time: 'timestamp',
|
|
2041
|
+
level: 'level',
|
|
2042
|
+
msg: 'message'
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
};
|
|
2046
|
+
```
|
|
2047
|
+
|
|
2048
|
+
---
|
|
2049
|
+
|
|
2050
|
+
# Messaging
|
|
2051
|
+
|
|
2052
|
+
The library provides generic producer and consumer contracts.
|
|
2053
|
+
|
|
2054
|
+
## Processing functions
|
|
2055
|
+
|
|
2056
|
+
```ts
|
|
2057
|
+
type Process<T, R, M> =
|
|
2058
|
+
(
|
|
2059
|
+
data: T,
|
|
2060
|
+
attributes?: StringMap,
|
|
2061
|
+
msg?: M
|
|
2062
|
+
) => Promise<R>;
|
|
2063
|
+
|
|
2064
|
+
type Handle<T, R, M> =
|
|
2065
|
+
(
|
|
2066
|
+
data: T,
|
|
2067
|
+
attributes?: StringMap,
|
|
2068
|
+
msg?: M
|
|
2069
|
+
) => Promise<R>;
|
|
2070
|
+
```
|
|
2071
|
+
|
|
2072
|
+
## Produce / write operations
|
|
2073
|
+
|
|
2074
|
+
```ts
|
|
2075
|
+
type Produce<T, R> =
|
|
2076
|
+
(data: T) => Promise<R>;
|
|
2077
|
+
```
|
|
2078
|
+
|
|
2079
|
+
Aliases are provided for common terminology:
|
|
2080
|
+
|
|
2081
|
+
```ts
|
|
2082
|
+
type Write<T, R> = Produce<T, R>;
|
|
2083
|
+
type Publish<T, R> = Produce<T, R>;
|
|
2084
|
+
type Send<T, R> = Produce<T, R>;
|
|
2085
|
+
type Put<T, R> = Produce<T, R>;
|
|
2086
|
+
type Set<T, R> = Produce<T, R>;
|
|
2087
|
+
```
|
|
2088
|
+
|
|
2089
|
+
## Consume operations
|
|
2090
|
+
|
|
2091
|
+
```ts
|
|
2092
|
+
type Consume<T> =
|
|
2093
|
+
(
|
|
2094
|
+
handle: (
|
|
2095
|
+
data: T,
|
|
2096
|
+
header?: StringMap
|
|
2097
|
+
) => Promise<number>
|
|
2098
|
+
) => void;
|
|
2099
|
+
```
|
|
2100
|
+
|
|
2101
|
+
Aliases include:
|
|
2102
|
+
|
|
2103
|
+
```ts
|
|
2104
|
+
type Read<T> = Consume<T>;
|
|
2105
|
+
type Subscribe<T> = Consume<T>;
|
|
2106
|
+
type Receive<T> = Consume<T>;
|
|
2107
|
+
type Get<T> = Consume<T>;
|
|
2108
|
+
type Fetch<T> = Consume<T>;
|
|
2109
|
+
```
|
|
2110
|
+
|
|
2111
|
+
---
|
|
2112
|
+
|
|
2113
|
+
# Message Envelope
|
|
2114
|
+
|
|
2115
|
+
`Message<T, ID, R>` represents a generic transport message:
|
|
2116
|
+
|
|
2117
|
+
```ts
|
|
2118
|
+
interface Message<T, ID, R> {
|
|
2119
|
+
id?: ID;
|
|
2120
|
+
data?: T;
|
|
2121
|
+
attributes?: StringMap;
|
|
2122
|
+
timestamp?: Date;
|
|
2123
|
+
raw?: R;
|
|
2124
|
+
}
|
|
2125
|
+
```
|
|
2126
|
+
|
|
2127
|
+
This allows application code to work with a normalized message representation while retaining the original transport payload through `raw`.
|
|
2128
|
+
|
|
2129
|
+
---
|
|
2130
|
+
|
|
2131
|
+
# Producers
|
|
2132
|
+
|
|
2133
|
+
```ts
|
|
2134
|
+
interface Producer<T, R, ID> {
|
|
2135
|
+
produce(
|
|
2136
|
+
to: string,
|
|
2137
|
+
data: T,
|
|
2138
|
+
attributes?: StringMap,
|
|
2139
|
+
id?: ID
|
|
2140
|
+
): Promise<R>;
|
|
2141
|
+
}
|
|
2142
|
+
```
|
|
2143
|
+
|
|
2144
|
+
Equivalent concepts are provided for publishing and sending:
|
|
2145
|
+
|
|
2146
|
+
```ts
|
|
2147
|
+
interface Publisher<T, R, ID> {
|
|
2148
|
+
publish(
|
|
2149
|
+
to: string,
|
|
2150
|
+
data: T,
|
|
2151
|
+
attributes?: StringMap,
|
|
2152
|
+
id?: ID
|
|
2153
|
+
): Promise<R>;
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
interface Sender<T, R, ID> {
|
|
2157
|
+
send(
|
|
2158
|
+
to: string,
|
|
2159
|
+
data: T,
|
|
2160
|
+
attributes?: StringMap,
|
|
2161
|
+
id?: ID
|
|
2162
|
+
): Promise<R>;
|
|
2163
|
+
}
|
|
2164
|
+
```
|
|
2165
|
+
|
|
2166
|
+
---
|
|
2167
|
+
|
|
2168
|
+
# Consumers
|
|
2169
|
+
|
|
2170
|
+
Several consumer styles are available:
|
|
2171
|
+
|
|
2172
|
+
```ts
|
|
2173
|
+
interface SimpleConsumer<T, R> {
|
|
2174
|
+
consume(
|
|
2175
|
+
process: (
|
|
2176
|
+
data: T,
|
|
2177
|
+
attributes?: StringMap,
|
|
2178
|
+
raw?: R
|
|
2179
|
+
) => Promise<number>
|
|
2180
|
+
): void;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
interface SimpleSubscriber<T, R> {
|
|
2184
|
+
subscribe(
|
|
2185
|
+
process: (
|
|
2186
|
+
data: T,
|
|
2187
|
+
attributes?: StringMap,
|
|
2188
|
+
raw?: R
|
|
2189
|
+
) => Promise<number>
|
|
2190
|
+
): void;
|
|
2191
|
+
}
|
|
2192
|
+
```
|
|
2193
|
+
|
|
2194
|
+
Message-envelope based consumers are also available:
|
|
2195
|
+
|
|
2196
|
+
```ts
|
|
2197
|
+
interface MessageSubscriber<T, ID, R> {
|
|
2198
|
+
subscribe(
|
|
2199
|
+
process: (
|
|
2200
|
+
data: Message<T, ID, R>
|
|
2201
|
+
) => Promise<number>
|
|
2202
|
+
): void;
|
|
2203
|
+
}
|
|
2204
|
+
```
|
|
2205
|
+
|
|
2206
|
+
---
|
|
2207
|
+
|
|
2208
|
+
# Retry
|
|
2209
|
+
|
|
2210
|
+
Retry configuration is represented by:
|
|
2211
|
+
|
|
2212
|
+
```ts
|
|
2213
|
+
interface RetryConfig {
|
|
2214
|
+
name: string;
|
|
2215
|
+
limit: number;
|
|
2216
|
+
}
|
|
2217
|
+
```
|
|
2218
|
+
|
|
2219
|
+
Concrete messaging implementations can use this configuration to determine retry policies.
|
|
2220
|
+
|
|
2221
|
+
---
|
|
2222
|
+
|
|
2223
|
+
# Design Principles
|
|
2224
|
+
|
|
2225
|
+
## Dependency inversion
|
|
2226
|
+
|
|
2227
|
+
Application code should depend on interfaces such as:
|
|
2228
|
+
|
|
2229
|
+
```text
|
|
2230
|
+
Repository
|
|
2231
|
+
CachePort
|
|
2232
|
+
Validator
|
|
2233
|
+
Logger
|
|
2234
|
+
Producer
|
|
2235
|
+
Consumer
|
|
2236
|
+
```
|
|
2237
|
+
|
|
2238
|
+
rather than concrete infrastructure implementations.
|
|
2239
|
+
|
|
2240
|
+
## Generic programming
|
|
2241
|
+
|
|
2242
|
+
Most contracts are generic:
|
|
2243
|
+
|
|
2244
|
+
```text
|
|
2245
|
+
T = entity/message data
|
|
2246
|
+
ID = entity/message identifier
|
|
2247
|
+
F = search-filter type
|
|
2248
|
+
R = raw/transport/result type
|
|
2249
|
+
```
|
|
2250
|
+
|
|
2251
|
+
This allows the same abstractions to be reused across different domains.
|
|
2252
|
+
|
|
2253
|
+
## Separation of concerns
|
|
2254
|
+
|
|
2255
|
+
Persistence, validation, application logic, infrastructure, and messaging are deliberately represented as separate contracts.
|
|
2256
|
+
|
|
2257
|
+
---
|
|
2258
|
+
|
|
2259
|
+
# Important Implementation Considerations
|
|
2260
|
+
|
|
2261
|
+
## SQL safety
|
|
2262
|
+
|
|
2263
|
+
Dynamic SQL identifiers such as:
|
|
2264
|
+
|
|
2265
|
+
```text
|
|
2266
|
+
field
|
|
2267
|
+
column
|
|
2268
|
+
sort
|
|
2269
|
+
fields
|
|
2270
|
+
```
|
|
2271
|
+
|
|
2272
|
+
must be validated against an allowlist before being included in SQL.
|
|
2273
|
+
|
|
2274
|
+
Query values should always be passed as parameters.
|
|
2275
|
+
|
|
2276
|
+
Do not build SQL by directly concatenating untrusted values.
|
|
2277
|
+
|
|
2278
|
+
## Transaction lifecycle
|
|
2279
|
+
|
|
2280
|
+
A failed `commit()` does not necessarily mean that a subsequent `rollback()` can safely or successfully complete the transaction. Concrete transaction implementations should explicitly manage transaction state and connection cleanup.
|
|
2281
|
+
|
|
2282
|
+
## Cache misses
|
|
2283
|
+
|
|
2284
|
+
Concrete `CachePort` implementations should define and document how `get()` behaves when a key does not exist.
|
|
2285
|
+
|
|
2286
|
+
## Message lifecycle
|
|
2287
|
+
|
|
2288
|
+
Messaging implementations should document:
|
|
2289
|
+
|
|
2290
|
+
* acknowledgment behavior
|
|
2291
|
+
* retry behavior
|
|
2292
|
+
* concurrency
|
|
2293
|
+
* ordering guarantees
|
|
2294
|
+
* error handling
|
|
2295
|
+
* cancellation/shutdown
|
|
2296
|
+
* backpressure
|
|
2297
|
+
|
|
2298
|
+
The interfaces themselves intentionally leave these transport-specific details open.
|
|
2299
|
+
|
|
2300
|
+
---
|
|
2301
|
+
|
|
2302
|
+
# Example Domain
|
|
2303
|
+
|
|
2304
|
+
A simple user repository can be modeled as:
|
|
2305
|
+
|
|
2306
|
+
```ts
|
|
2307
|
+
interface User {
|
|
2308
|
+
id: string;
|
|
2309
|
+
name: string;
|
|
2310
|
+
email: string;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
interface UserFilter extends Filter {
|
|
2314
|
+
role?: string;
|
|
2315
|
+
active?: boolean;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
interface UserRepository
|
|
2319
|
+
extends Repository<User, string, UserFilter> {}
|
|
2320
|
+
```
|
|
2321
|
+
|
|
2322
|
+
The application layer can then depend on:
|
|
2323
|
+
|
|
2324
|
+
```ts
|
|
2325
|
+
class UserUseCase
|
|
2326
|
+
extends UseCase<User, string, UserFilter> {}
|
|
2327
|
+
```
|
|
2328
|
+
|
|
2329
|
+
A concrete infrastructure adapter implements `UserRepository` using the database implementation behind `Executor`.
|
|
2330
|
+
|
|
2331
|
+
---
|
|
2332
|
+
|
|
2333
|
+
# Recommended Project Structure
|
|
2334
|
+
|
|
2335
|
+
A project using this library can be organized as:
|
|
2336
|
+
|
|
2337
|
+
```text
|
|
2338
|
+
src/
|
|
2339
|
+
├── domain/
|
|
2340
|
+
│ └── user/
|
|
2341
|
+
│ ├── user.ts
|
|
2342
|
+
│ └── user-filter.ts
|
|
2343
|
+
│
|
|
2344
|
+
├── application/
|
|
2345
|
+
│ └── user/
|
|
2346
|
+
│ └── user-use-case.ts
|
|
2347
|
+
│
|
|
2348
|
+
├── repository/
|
|
2349
|
+
│ └── user-repository.ts
|
|
2350
|
+
│
|
|
2351
|
+
├── infrastructure/
|
|
2352
|
+
│ ├── database/
|
|
2353
|
+
│ │ ├── executor.ts
|
|
2354
|
+
│ │ └── transaction.ts
|
|
2355
|
+
│ ├── cache/
|
|
2356
|
+
│ ├── logging/
|
|
2357
|
+
│ └── messaging/
|
|
2358
|
+
│
|
|
2359
|
+
└── validation/
|
|
2360
|
+
└── user-validator.ts
|
|
2361
|
+
```
|
|
2362
|
+
|
|
2363
|
+
---
|
|
2364
|
+
|
|
2365
|
+
# Summary
|
|
2366
|
+
|
|
2367
|
+
This package provides a reusable set of TypeScript contracts for applications that follow a layered or ports-and-adapters architecture.
|
|
2368
|
+
|
|
2369
|
+
The central flow is:
|
|
2370
|
+
|
|
2371
|
+
```text
|
|
2372
|
+
┌──────────────┐
|
|
2373
|
+
│ Controller │
|
|
2374
|
+
└──────┬───────┘
|
|
2375
|
+
│
|
|
2376
|
+
▼
|
|
2377
|
+
┌──────────────┐
|
|
2378
|
+
│ UseCase │
|
|
2379
|
+
└──────┬───────┘
|
|
2380
|
+
│
|
|
2381
|
+
▼
|
|
2382
|
+
┌──────────────┐
|
|
2383
|
+
│ Repository │
|
|
2384
|
+
└──────┬───────┘
|
|
2385
|
+
│
|
|
2386
|
+
▼
|
|
2387
|
+
┌──────────────┐
|
|
2388
|
+
│ Executor │
|
|
2389
|
+
└──────┬───────┘
|
|
2390
|
+
│
|
|
2391
|
+
▼
|
|
2392
|
+
Database
|
|
2393
|
+
```
|
|
2394
|
+
|
|
2395
|
+
Supporting infrastructure is exposed through independent ports:
|
|
2396
|
+
|
|
2397
|
+
```text
|
|
2398
|
+
Application
|
|
2399
|
+
/ | \
|
|
2400
|
+
/ | \
|
|
2401
|
+
Validator Cache Logger
|
|
2402
|
+
\
|
|
2403
|
+
Messaging
|
|
2404
|
+
```
|
|
2405
|
+
|
|
2406
|
+
The interfaces are intentionally implementation-independent, making the package suitable as a shared foundation for multiple services, databases, cache systems, and messaging transports.
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
MIT
|