@nest-yalc-2/omnikernel-module 2.2.2 → 2.2.4
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/package.json +17 -15
- package/src/__tests__/omni-collection.entity.spec.ts +49 -49
- package/src/__tests__/omni-collection.service.spec.ts +83 -83
- package/src/__tests__/omni-document.entity.spec.ts +55 -55
- package/src/__tests__/omni-document.service.spec.ts +83 -83
- package/src/__tests__/omni-extension-projection-constraints.spec.ts +518 -518
- package/src/__tests__/omni-extension-projection.registration.spec.ts +947 -946
- package/src/__tests__/omni-extension-projection.service.spec.ts +806 -806
- package/src/__tests__/omni-external-ref.entity.spec.ts +36 -36
- package/src/__tests__/omni-external-ref.service.spec.ts +252 -252
- package/src/__tests__/omni-named.entity.spec.ts +33 -33
- package/src/__tests__/omni-record.entity.spec.ts +32 -32
- package/src/__tests__/omni-record.service.spec.ts +107 -107
- package/src/__tests__/omni-relation-semantics.spec.ts +102 -102
- package/src/__tests__/omni-relation.entity.spec.ts +38 -38
- package/src/__tests__/omni-scope-substrate.spec.ts +81 -81
- package/src/__tests__/omni-scoped.backend.spec.ts +115 -115
- package/src/__tests__/omni-scoped.service.spec.ts +354 -354
- package/src/__tests__/omnikernel.diagnostics.spec.ts +94 -94
- package/src/__tests__/omnikernel.module.spec.ts +38 -40
- package/src/__tests__/omnikernel.persistence.spec.ts +154 -154
- package/src/__tests__/omnikernel.public-api.spec.ts +238 -238
- package/src/__tests__/omnikernel.query.service.spec.ts +155 -155
- package/src/base/omni-base.entity.ts +41 -41
- package/src/base/omni-external-ref.entity.ts +53 -53
- package/src/base/omni-named.entity.ts +16 -16
- package/src/base/omni-record.entity.ts +51 -51
- package/src/base/omni-relation.entity.ts +72 -72
- package/src/index.ts +49 -49
- package/src/omni-collection-kind.enum.ts +10 -10
- package/src/omni-collection.backend.ts +16 -16
- package/src/omni-collection.dto.ts +170 -170
- package/src/omni-collection.entity.ts +21 -21
- package/src/omni-collection.service.ts +75 -75
- package/src/omni-document-kind.enum.ts +12 -12
- package/src/omni-document.backend.ts +16 -16
- package/src/omni-document.dto.ts +198 -198
- package/src/omni-document.entity.ts +30 -30
- package/src/omni-document.service.ts +75 -75
- package/src/omni-dto.helpers.ts +21 -21
- package/src/omni-extension-projection.definition.ts +294 -294
- package/src/omni-extension-projection.resource.ts +274 -274
- package/src/omni-extension-projection.service.ts +452 -452
- package/src/omni-external-ref-binding.validator.ts +53 -53
- package/src/omni-external-ref-internal-type.enum.ts +11 -11
- package/src/omni-external-ref.backend.ts +25 -25
- package/src/omni-external-ref.dto.ts +128 -128
- package/src/omni-external-ref.service.ts +255 -255
- package/src/omni-migration.ts +365 -365
- package/src/omni-named.backend.ts +11 -11
- package/src/omni-named.dto.ts +86 -86
- package/src/omni-projection.catalog.ts +345 -345
- package/src/omni-projection.lifecycle.ts +53 -53
- package/src/omni-record-status.enum.ts +11 -11
- package/src/omni-record.backend.ts +19 -19
- package/src/omni-record.dto.ts +157 -157
- package/src/omni-record.service.ts +113 -113
- package/src/omni-relation-kind.contract.ts +49 -49
- package/src/omni-relation-kind.enum.ts +12 -12
- package/src/omni-relation-projection.definition.ts +166 -166
- package/src/omni-relation-projection.resource.ts +360 -360
- package/src/omni-relation-projection.service.ts +610 -610
- package/src/omni-relation-semantics.ts +52 -52
- package/src/omni-relation-status.enum.ts +11 -11
- package/src/omni-relation.backend.ts +21 -21
- package/src/omni-relation.dto.ts +142 -142
- package/src/omni-relation.service.ts +159 -159
- package/src/omni-scope.ts +161 -161
- package/src/omni-scoped.backend.ts +110 -110
- package/src/omni-scoped.repository.ts +34 -34
- package/src/omni-scoped.service.ts +338 -338
- package/src/omnikernel.diagnostics.ts +83 -83
- package/src/omnikernel.module.ts +97 -97
- package/src/omnikernel.query.service.ts +108 -108
|
@@ -1,338 +1,338 @@
|
|
|
1
|
-
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
2
|
-
import type { CrudGenFindManyOptions } from '@nest-yalc-2/crud-gen/api-graphql/crud-gen-gql.interface.js';
|
|
3
|
-
import type { IWhereCondition } from '@nest-yalc-2/crud-gen/api-graphql/crud-gen-gql.type.js';
|
|
4
|
-
import { Operators } from '@nest-yalc-2/crud-gen/crud-gen.enum.js';
|
|
5
|
-
import { GenericService } from '@nest-yalc-2/crud-gen/typeorm/generic.service.js';
|
|
6
|
-
import type { DeepPartial, FindOptionsWhere, ObjectLiteral } from 'typeorm';
|
|
7
|
-
import { IsNull } from 'typeorm';
|
|
8
|
-
import type { OmniDeletePolicy, OmniScope } from './omni-scope.js';
|
|
9
|
-
|
|
10
|
-
type ScopedEntity = ObjectLiteral & {
|
|
11
|
-
scopeId: string;
|
|
12
|
-
deletedAt?: Date | null;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function hasOwn(input: object, key: string): boolean {
|
|
16
|
-
return Object.prototype.hasOwnProperty.call(input, key);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* The shared service boundary for scoped Omni CRUD. Every lookup, mutation,
|
|
21
|
-
* and generated grid is scoped before it reaches the generic CrudGen service.
|
|
22
|
-
*/
|
|
23
|
-
export class OmniScopedService<
|
|
24
|
-
Entity extends ScopedEntity,
|
|
25
|
-
> extends GenericService<Entity> {
|
|
26
|
-
constructor(
|
|
27
|
-
repository: any,
|
|
28
|
-
scopeOrRepositoryWrite?: OmniScope | any,
|
|
29
|
-
private readonly deletion: OmniDeletePolicy = 'hard',
|
|
30
|
-
) {
|
|
31
|
-
const scope = isOmniScope(scopeOrRepositoryWrite)
|
|
32
|
-
? scopeOrRepositoryWrite
|
|
33
|
-
: defaultOmniScope;
|
|
34
|
-
super(
|
|
35
|
-
repository,
|
|
36
|
-
isOmniScope(scopeOrRepositoryWrite) ? undefined : scopeOrRepositoryWrite,
|
|
37
|
-
);
|
|
38
|
-
this.scope = scope;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
protected readonly scope: OmniScope;
|
|
42
|
-
|
|
43
|
-
protected get scopeId(): string {
|
|
44
|
-
return this.scope.scopeId;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
protected scopedConditions(
|
|
48
|
-
conditions: FindOptionsWhere<Entity>,
|
|
49
|
-
): FindOptionsWhere<Entity> {
|
|
50
|
-
this.rejectClientScope(conditions as object);
|
|
51
|
-
return {
|
|
52
|
-
...conditions,
|
|
53
|
-
scopeId: this.scopeId,
|
|
54
|
-
...(this.deletion === 'tombstone' ? { deletedAt: IsNull() } : {}),
|
|
55
|
-
} as unknown as FindOptionsWhere<Entity>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
override async getEntity(
|
|
59
|
-
conditions:
|
|
60
|
-
| FindOptionsWhere<Entity>[]
|
|
61
|
-
| FindOptionsWhere<Entity>
|
|
62
|
-
| ObjectLiteral
|
|
63
|
-
| string,
|
|
64
|
-
fields?: (keyof Entity)[],
|
|
65
|
-
relations?: string[],
|
|
66
|
-
databaseName?: string,
|
|
67
|
-
options?: { failOnNull: false },
|
|
68
|
-
): Promise<Entity | undefined>;
|
|
69
|
-
override async getEntity(
|
|
70
|
-
conditions:
|
|
71
|
-
| FindOptionsWhere<Entity>[]
|
|
72
|
-
| FindOptionsWhere<Entity>
|
|
73
|
-
| ObjectLiteral
|
|
74
|
-
| string,
|
|
75
|
-
fields?: (keyof Entity)[],
|
|
76
|
-
relations?: string[],
|
|
77
|
-
databaseName?: string,
|
|
78
|
-
options?: { failOnNull?: boolean },
|
|
79
|
-
): Promise<Entity>;
|
|
80
|
-
override async getEntity(
|
|
81
|
-
conditions:
|
|
82
|
-
| FindOptionsWhere<Entity>[]
|
|
83
|
-
| FindOptionsWhere<Entity>
|
|
84
|
-
| ObjectLiteral
|
|
85
|
-
| string,
|
|
86
|
-
fields?: (keyof Entity)[],
|
|
87
|
-
relations?: string[],
|
|
88
|
-
databaseName?: string,
|
|
89
|
-
options?: { failOnNull?: boolean },
|
|
90
|
-
): Promise<Entity | undefined> {
|
|
91
|
-
const entity = await super.getEntity(
|
|
92
|
-
this.scopedWhere(conditions),
|
|
93
|
-
fields,
|
|
94
|
-
relations,
|
|
95
|
-
databaseName,
|
|
96
|
-
{ failOnNull: false },
|
|
97
|
-
);
|
|
98
|
-
if (!entity && options?.failOnNull) this.notFound();
|
|
99
|
-
return entity;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
override async getEntityListExtended(
|
|
103
|
-
findOptions: CrudGenFindManyOptions<Entity>,
|
|
104
|
-
withCount?: false,
|
|
105
|
-
relations?: string[],
|
|
106
|
-
databaseName?: string,
|
|
107
|
-
): Promise<Entity[]>;
|
|
108
|
-
override async getEntityListExtended(
|
|
109
|
-
findOptions: CrudGenFindManyOptions<Entity>,
|
|
110
|
-
withCount: true,
|
|
111
|
-
relations?: string[],
|
|
112
|
-
databaseName?: string,
|
|
113
|
-
): Promise<[Entity[], number]>;
|
|
114
|
-
override async getEntityListExtended(
|
|
115
|
-
findOptions: CrudGenFindManyOptions<Entity> = {},
|
|
116
|
-
withCount = false,
|
|
117
|
-
relations?: string[],
|
|
118
|
-
databaseName?: string,
|
|
119
|
-
): Promise<Entity[] | [Entity[], number]> {
|
|
120
|
-
const scopedOptions = this.scopeFindOptions(findOptions);
|
|
121
|
-
if (withCount) {
|
|
122
|
-
return super.getEntityListExtended(
|
|
123
|
-
scopedOptions,
|
|
124
|
-
true,
|
|
125
|
-
relations,
|
|
126
|
-
databaseName,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
return super.getEntityListExtended(
|
|
130
|
-
scopedOptions,
|
|
131
|
-
false,
|
|
132
|
-
relations,
|
|
133
|
-
databaseName,
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private scopeFindOptions(
|
|
138
|
-
findOptions: CrudGenFindManyOptions<Entity>,
|
|
139
|
-
): CrudGenFindManyOptions<Entity> {
|
|
140
|
-
this.rejectScopeInWhere(findOptions.where);
|
|
141
|
-
const userWhere = findOptions.where;
|
|
142
|
-
const filters: IWhereCondition<Entity>['filters'] = {};
|
|
143
|
-
filters.scopeId = this.scopeId;
|
|
144
|
-
if (this.deletion === 'tombstone') filters.deletedAt = IsNull();
|
|
145
|
-
const where: IWhereCondition<Entity> = {
|
|
146
|
-
operator: Operators.AND,
|
|
147
|
-
filters,
|
|
148
|
-
...(userWhere ? { childExpressions: [userWhere] } : {}),
|
|
149
|
-
};
|
|
150
|
-
return {
|
|
151
|
-
...findOptions,
|
|
152
|
-
where,
|
|
153
|
-
...(findOptions.subQueryFilters
|
|
154
|
-
? {
|
|
155
|
-
subQueryFilters: this.scopeFindOptions(findOptions.subQueryFilters),
|
|
156
|
-
}
|
|
157
|
-
: {}),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
override async createEntity(
|
|
162
|
-
input: DeepPartial<Entity>,
|
|
163
|
-
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
164
|
-
returnEntity?: true,
|
|
165
|
-
): Promise<Entity>;
|
|
166
|
-
override async createEntity(
|
|
167
|
-
input: DeepPartial<Entity>,
|
|
168
|
-
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
169
|
-
returnEntity?: boolean,
|
|
170
|
-
): Promise<Entity | boolean>;
|
|
171
|
-
override async createEntity(
|
|
172
|
-
input: DeepPartial<Entity>,
|
|
173
|
-
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
174
|
-
returnEntity = true,
|
|
175
|
-
): Promise<Entity | boolean> {
|
|
176
|
-
this.rejectServerFields(input as object);
|
|
177
|
-
this.validatePayloadContract(input as object);
|
|
178
|
-
return super.createEntity(
|
|
179
|
-
{ ...input, scopeId: this.scopeId } as DeepPartial<Entity>,
|
|
180
|
-
findOptions,
|
|
181
|
-
returnEntity,
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
override async updateEntity(
|
|
186
|
-
conditions: FindOptionsWhere<Entity>,
|
|
187
|
-
input: DeepPartial<Entity>,
|
|
188
|
-
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
189
|
-
returnEntity?: true,
|
|
190
|
-
): Promise<Entity>;
|
|
191
|
-
override async updateEntity(
|
|
192
|
-
conditions: FindOptionsWhere<Entity>,
|
|
193
|
-
input: DeepPartial<Entity>,
|
|
194
|
-
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
195
|
-
returnEntity?: boolean,
|
|
196
|
-
): Promise<Entity | boolean>;
|
|
197
|
-
override async updateEntity(
|
|
198
|
-
conditions: FindOptionsWhere<Entity>,
|
|
199
|
-
input: DeepPartial<Entity>,
|
|
200
|
-
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
201
|
-
returnEntity = true,
|
|
202
|
-
): Promise<Entity | boolean> {
|
|
203
|
-
this.rejectServerFields(input as object);
|
|
204
|
-
this.validatePayloadContract(input as object);
|
|
205
|
-
if (hasOwn(input as object, 'guid')) {
|
|
206
|
-
throw new BadRequestException('guid is immutable.');
|
|
207
|
-
}
|
|
208
|
-
return super.updateEntity(
|
|
209
|
-
this.scopedConditions(conditions),
|
|
210
|
-
{ ...input, scopeId: this.scopeId } as DeepPartial<Entity>,
|
|
211
|
-
findOptions,
|
|
212
|
-
returnEntity,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
override async deleteEntity(
|
|
217
|
-
conditions: FindOptionsWhere<Entity>,
|
|
218
|
-
): Promise<boolean> {
|
|
219
|
-
const scoped = this.scopedConditions(conditions);
|
|
220
|
-
if (this.deletion === 'hard') {
|
|
221
|
-
return super.deleteEntity(scoped);
|
|
222
|
-
}
|
|
223
|
-
const result = await this.getRepository().update(scoped, {
|
|
224
|
-
deletedAt: new Date(),
|
|
225
|
-
} as DeepPartial<Entity>);
|
|
226
|
-
if (!result.affected) this.notFound();
|
|
227
|
-
return true;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
protected rejectServerFields(input: object): void {
|
|
231
|
-
for (const field of ['scopeId', 'revision', 'deletedAt']) {
|
|
232
|
-
if (hasOwn(input, field)) {
|
|
233
|
-
throw new BadRequestException(`${field} is server-owned.`);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
protected rejectClientScope(input: object): void {
|
|
239
|
-
if (hasOwn(input, 'scopeId')) {
|
|
240
|
-
throw new BadRequestException('scopeId is derived from server context.');
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
private validatePayloadContract(input: object): void {
|
|
245
|
-
const candidate = input as Record<string, unknown>;
|
|
246
|
-
if (
|
|
247
|
-
hasOwn(candidate, 'payload') &&
|
|
248
|
-
candidate.payload !== null &&
|
|
249
|
-
(typeof candidate.payload !== 'object' ||
|
|
250
|
-
Array.isArray(candidate.payload))
|
|
251
|
-
) {
|
|
252
|
-
throw new BadRequestException('payload must be a JSON object or null.');
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const hasSchemaId = hasOwn(candidate, 'payloadSchemaId');
|
|
256
|
-
const hasSchemaVersion = hasOwn(candidate, 'payloadSchemaVersion');
|
|
257
|
-
if (hasSchemaId !== hasSchemaVersion) {
|
|
258
|
-
throw new BadRequestException(
|
|
259
|
-
'payloadSchemaId and payloadSchemaVersion must be supplied together.',
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
if (!hasSchemaId) return;
|
|
263
|
-
|
|
264
|
-
if (
|
|
265
|
-
typeof candidate.payloadSchemaId !== 'string' ||
|
|
266
|
-
candidate.payloadSchemaId.trim().length === 0 ||
|
|
267
|
-
candidate.payloadSchemaId.length > 128
|
|
268
|
-
) {
|
|
269
|
-
throw new BadRequestException(
|
|
270
|
-
'payloadSchemaId must be a non-empty string.',
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
if (
|
|
274
|
-
typeof candidate.payloadSchemaVersion !== 'number' ||
|
|
275
|
-
!Number.isInteger(candidate.payloadSchemaVersion) ||
|
|
276
|
-
candidate.payloadSchemaVersion < 1 ||
|
|
277
|
-
candidate.payloadSchemaVersion > 2_147_483_647
|
|
278
|
-
) {
|
|
279
|
-
throw new BadRequestException(
|
|
280
|
-
'payloadSchemaVersion must be a positive signed 32-bit integer.',
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
protected notFound(): never {
|
|
286
|
-
throw new NotFoundException('Omni resource was not found in this scope.');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
private scopedWhere(
|
|
290
|
-
where:
|
|
291
|
-
| FindOptionsWhere<Entity>[]
|
|
292
|
-
| FindOptionsWhere<Entity>
|
|
293
|
-
| ObjectLiteral
|
|
294
|
-
| string,
|
|
295
|
-
): FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[] {
|
|
296
|
-
if (typeof where === 'string') {
|
|
297
|
-
throw new BadRequestException(
|
|
298
|
-
'String where clauses are not supported for scoped Omni resources.',
|
|
299
|
-
);
|
|
300
|
-
}
|
|
301
|
-
if (Array.isArray(where)) {
|
|
302
|
-
return where.map((condition) => this.scopedConditions(condition));
|
|
303
|
-
}
|
|
304
|
-
return this.scopedConditions(where as FindOptionsWhere<Entity>);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
private rejectScopeInWhere(where: unknown): void {
|
|
308
|
-
if (!where || typeof where !== 'object') return;
|
|
309
|
-
if (Array.isArray(where)) {
|
|
310
|
-
where.forEach((item) => this.rejectScopeInWhere(item));
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
const candidate = where as Record<string, unknown>;
|
|
314
|
-
if (hasOwn(candidate, 'scopeId')) this.rejectClientScope(candidate);
|
|
315
|
-
if (candidate.filters && typeof candidate.filters === 'object') {
|
|
316
|
-
this.rejectScopeInWhere(candidate.filters);
|
|
317
|
-
}
|
|
318
|
-
if (Array.isArray(candidate.childExpressions)) {
|
|
319
|
-
candidate.childExpressions.forEach((item) =>
|
|
320
|
-
this.rejectScopeInWhere(item),
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function isOmniScope(value: unknown): value is OmniScope {
|
|
327
|
-
return (
|
|
328
|
-
!!value &&
|
|
329
|
-
typeof value === 'object' &&
|
|
330
|
-
typeof (value as OmniScope).scopeId === 'string' &&
|
|
331
|
-
typeof (value as OmniScope).cacheKey === 'function'
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const defaultOmniScope: OmniScope = {
|
|
336
|
-
scopeId: 'default',
|
|
337
|
-
cacheKey: (key) => `default:${key}`,
|
|
338
|
-
};
|
|
1
|
+
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import type { CrudGenFindManyOptions } from '@nest-yalc-2/crud-gen/api-graphql/crud-gen-gql.interface.js';
|
|
3
|
+
import type { IWhereCondition } from '@nest-yalc-2/crud-gen/api-graphql/crud-gen-gql.type.js';
|
|
4
|
+
import { Operators } from '@nest-yalc-2/crud-gen/crud-gen.enum.js';
|
|
5
|
+
import { GenericService } from '@nest-yalc-2/crud-gen/typeorm/generic.service.js';
|
|
6
|
+
import type { DeepPartial, FindOptionsWhere, ObjectLiteral } from 'typeorm';
|
|
7
|
+
import { IsNull } from 'typeorm';
|
|
8
|
+
import type { OmniDeletePolicy, OmniScope } from './omni-scope.js';
|
|
9
|
+
|
|
10
|
+
type ScopedEntity = ObjectLiteral & {
|
|
11
|
+
scopeId: string;
|
|
12
|
+
deletedAt?: Date | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function hasOwn(input: object, key: string): boolean {
|
|
16
|
+
return Object.prototype.hasOwnProperty.call(input, key);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The shared service boundary for scoped Omni CRUD. Every lookup, mutation,
|
|
21
|
+
* and generated grid is scoped before it reaches the generic CrudGen service.
|
|
22
|
+
*/
|
|
23
|
+
export class OmniScopedService<
|
|
24
|
+
Entity extends ScopedEntity,
|
|
25
|
+
> extends GenericService<Entity> {
|
|
26
|
+
constructor(
|
|
27
|
+
repository: any,
|
|
28
|
+
scopeOrRepositoryWrite?: OmniScope | any,
|
|
29
|
+
private readonly deletion: OmniDeletePolicy = 'hard',
|
|
30
|
+
) {
|
|
31
|
+
const scope = isOmniScope(scopeOrRepositoryWrite)
|
|
32
|
+
? scopeOrRepositoryWrite
|
|
33
|
+
: defaultOmniScope;
|
|
34
|
+
super(
|
|
35
|
+
repository,
|
|
36
|
+
isOmniScope(scopeOrRepositoryWrite) ? undefined : scopeOrRepositoryWrite,
|
|
37
|
+
);
|
|
38
|
+
this.scope = scope;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected readonly scope: OmniScope;
|
|
42
|
+
|
|
43
|
+
protected get scopeId(): string {
|
|
44
|
+
return this.scope.scopeId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
protected scopedConditions(
|
|
48
|
+
conditions: FindOptionsWhere<Entity>,
|
|
49
|
+
): FindOptionsWhere<Entity> {
|
|
50
|
+
this.rejectClientScope(conditions as object);
|
|
51
|
+
return {
|
|
52
|
+
...conditions,
|
|
53
|
+
scopeId: this.scopeId,
|
|
54
|
+
...(this.deletion === 'tombstone' ? { deletedAt: IsNull() } : {}),
|
|
55
|
+
} as unknown as FindOptionsWhere<Entity>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override async getEntity(
|
|
59
|
+
conditions:
|
|
60
|
+
| FindOptionsWhere<Entity>[]
|
|
61
|
+
| FindOptionsWhere<Entity>
|
|
62
|
+
| ObjectLiteral
|
|
63
|
+
| string,
|
|
64
|
+
fields?: (keyof Entity)[],
|
|
65
|
+
relations?: string[],
|
|
66
|
+
databaseName?: string,
|
|
67
|
+
options?: { failOnNull: false },
|
|
68
|
+
): Promise<Entity | undefined>;
|
|
69
|
+
override async getEntity(
|
|
70
|
+
conditions:
|
|
71
|
+
| FindOptionsWhere<Entity>[]
|
|
72
|
+
| FindOptionsWhere<Entity>
|
|
73
|
+
| ObjectLiteral
|
|
74
|
+
| string,
|
|
75
|
+
fields?: (keyof Entity)[],
|
|
76
|
+
relations?: string[],
|
|
77
|
+
databaseName?: string,
|
|
78
|
+
options?: { failOnNull?: boolean },
|
|
79
|
+
): Promise<Entity>;
|
|
80
|
+
override async getEntity(
|
|
81
|
+
conditions:
|
|
82
|
+
| FindOptionsWhere<Entity>[]
|
|
83
|
+
| FindOptionsWhere<Entity>
|
|
84
|
+
| ObjectLiteral
|
|
85
|
+
| string,
|
|
86
|
+
fields?: (keyof Entity)[],
|
|
87
|
+
relations?: string[],
|
|
88
|
+
databaseName?: string,
|
|
89
|
+
options?: { failOnNull?: boolean },
|
|
90
|
+
): Promise<Entity | undefined> {
|
|
91
|
+
const entity = await super.getEntity(
|
|
92
|
+
this.scopedWhere(conditions),
|
|
93
|
+
fields,
|
|
94
|
+
relations,
|
|
95
|
+
databaseName,
|
|
96
|
+
{ failOnNull: false },
|
|
97
|
+
);
|
|
98
|
+
if (!entity && options?.failOnNull) this.notFound();
|
|
99
|
+
return entity;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override async getEntityListExtended(
|
|
103
|
+
findOptions: CrudGenFindManyOptions<Entity>,
|
|
104
|
+
withCount?: false,
|
|
105
|
+
relations?: string[],
|
|
106
|
+
databaseName?: string,
|
|
107
|
+
): Promise<Entity[]>;
|
|
108
|
+
override async getEntityListExtended(
|
|
109
|
+
findOptions: CrudGenFindManyOptions<Entity>,
|
|
110
|
+
withCount: true,
|
|
111
|
+
relations?: string[],
|
|
112
|
+
databaseName?: string,
|
|
113
|
+
): Promise<[Entity[], number]>;
|
|
114
|
+
override async getEntityListExtended(
|
|
115
|
+
findOptions: CrudGenFindManyOptions<Entity> = {},
|
|
116
|
+
withCount = false,
|
|
117
|
+
relations?: string[],
|
|
118
|
+
databaseName?: string,
|
|
119
|
+
): Promise<Entity[] | [Entity[], number]> {
|
|
120
|
+
const scopedOptions = this.scopeFindOptions(findOptions);
|
|
121
|
+
if (withCount) {
|
|
122
|
+
return super.getEntityListExtended(
|
|
123
|
+
scopedOptions,
|
|
124
|
+
true,
|
|
125
|
+
relations,
|
|
126
|
+
databaseName,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return super.getEntityListExtended(
|
|
130
|
+
scopedOptions,
|
|
131
|
+
false,
|
|
132
|
+
relations,
|
|
133
|
+
databaseName,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private scopeFindOptions(
|
|
138
|
+
findOptions: CrudGenFindManyOptions<Entity>,
|
|
139
|
+
): CrudGenFindManyOptions<Entity> {
|
|
140
|
+
this.rejectScopeInWhere(findOptions.where);
|
|
141
|
+
const userWhere = findOptions.where;
|
|
142
|
+
const filters: IWhereCondition<Entity>['filters'] = {};
|
|
143
|
+
filters.scopeId = this.scopeId;
|
|
144
|
+
if (this.deletion === 'tombstone') filters.deletedAt = IsNull();
|
|
145
|
+
const where: IWhereCondition<Entity> = {
|
|
146
|
+
operator: Operators.AND,
|
|
147
|
+
filters,
|
|
148
|
+
...(userWhere ? { childExpressions: [userWhere] } : {}),
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
...findOptions,
|
|
152
|
+
where,
|
|
153
|
+
...(findOptions.subQueryFilters
|
|
154
|
+
? {
|
|
155
|
+
subQueryFilters: this.scopeFindOptions(findOptions.subQueryFilters),
|
|
156
|
+
}
|
|
157
|
+
: {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
override async createEntity(
|
|
162
|
+
input: DeepPartial<Entity>,
|
|
163
|
+
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
164
|
+
returnEntity?: true,
|
|
165
|
+
): Promise<Entity>;
|
|
166
|
+
override async createEntity(
|
|
167
|
+
input: DeepPartial<Entity>,
|
|
168
|
+
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
169
|
+
returnEntity?: boolean,
|
|
170
|
+
): Promise<Entity | boolean>;
|
|
171
|
+
override async createEntity(
|
|
172
|
+
input: DeepPartial<Entity>,
|
|
173
|
+
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
174
|
+
returnEntity = true,
|
|
175
|
+
): Promise<Entity | boolean> {
|
|
176
|
+
this.rejectServerFields(input as object);
|
|
177
|
+
this.validatePayloadContract(input as object);
|
|
178
|
+
return super.createEntity(
|
|
179
|
+
{ ...input, scopeId: this.scopeId } as DeepPartial<Entity>,
|
|
180
|
+
findOptions,
|
|
181
|
+
returnEntity,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
override async updateEntity(
|
|
186
|
+
conditions: FindOptionsWhere<Entity>,
|
|
187
|
+
input: DeepPartial<Entity>,
|
|
188
|
+
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
189
|
+
returnEntity?: true,
|
|
190
|
+
): Promise<Entity>;
|
|
191
|
+
override async updateEntity(
|
|
192
|
+
conditions: FindOptionsWhere<Entity>,
|
|
193
|
+
input: DeepPartial<Entity>,
|
|
194
|
+
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
195
|
+
returnEntity?: boolean,
|
|
196
|
+
): Promise<Entity | boolean>;
|
|
197
|
+
override async updateEntity(
|
|
198
|
+
conditions: FindOptionsWhere<Entity>,
|
|
199
|
+
input: DeepPartial<Entity>,
|
|
200
|
+
findOptions?: CrudGenFindManyOptions<Entity>,
|
|
201
|
+
returnEntity = true,
|
|
202
|
+
): Promise<Entity | boolean> {
|
|
203
|
+
this.rejectServerFields(input as object);
|
|
204
|
+
this.validatePayloadContract(input as object);
|
|
205
|
+
if (hasOwn(input as object, 'guid')) {
|
|
206
|
+
throw new BadRequestException('guid is immutable.');
|
|
207
|
+
}
|
|
208
|
+
return super.updateEntity(
|
|
209
|
+
this.scopedConditions(conditions),
|
|
210
|
+
{ ...input, scopeId: this.scopeId } as DeepPartial<Entity>,
|
|
211
|
+
findOptions,
|
|
212
|
+
returnEntity,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
override async deleteEntity(
|
|
217
|
+
conditions: FindOptionsWhere<Entity>,
|
|
218
|
+
): Promise<boolean> {
|
|
219
|
+
const scoped = this.scopedConditions(conditions);
|
|
220
|
+
if (this.deletion === 'hard') {
|
|
221
|
+
return super.deleteEntity(scoped);
|
|
222
|
+
}
|
|
223
|
+
const result = await this.getRepository().update(scoped, {
|
|
224
|
+
deletedAt: new Date(),
|
|
225
|
+
} as DeepPartial<Entity>);
|
|
226
|
+
if (!result.affected) this.notFound();
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
protected rejectServerFields(input: object): void {
|
|
231
|
+
for (const field of ['scopeId', 'revision', 'deletedAt']) {
|
|
232
|
+
if (hasOwn(input, field)) {
|
|
233
|
+
throw new BadRequestException(`${field} is server-owned.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
protected rejectClientScope(input: object): void {
|
|
239
|
+
if (hasOwn(input, 'scopeId')) {
|
|
240
|
+
throw new BadRequestException('scopeId is derived from server context.');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private validatePayloadContract(input: object): void {
|
|
245
|
+
const candidate = input as Record<string, unknown>;
|
|
246
|
+
if (
|
|
247
|
+
hasOwn(candidate, 'payload') &&
|
|
248
|
+
candidate.payload !== null &&
|
|
249
|
+
(typeof candidate.payload !== 'object' ||
|
|
250
|
+
Array.isArray(candidate.payload))
|
|
251
|
+
) {
|
|
252
|
+
throw new BadRequestException('payload must be a JSON object or null.');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const hasSchemaId = hasOwn(candidate, 'payloadSchemaId');
|
|
256
|
+
const hasSchemaVersion = hasOwn(candidate, 'payloadSchemaVersion');
|
|
257
|
+
if (hasSchemaId !== hasSchemaVersion) {
|
|
258
|
+
throw new BadRequestException(
|
|
259
|
+
'payloadSchemaId and payloadSchemaVersion must be supplied together.',
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
if (!hasSchemaId) return;
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
typeof candidate.payloadSchemaId !== 'string' ||
|
|
266
|
+
candidate.payloadSchemaId.trim().length === 0 ||
|
|
267
|
+
candidate.payloadSchemaId.length > 128
|
|
268
|
+
) {
|
|
269
|
+
throw new BadRequestException(
|
|
270
|
+
'payloadSchemaId must be a non-empty string.',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (
|
|
274
|
+
typeof candidate.payloadSchemaVersion !== 'number' ||
|
|
275
|
+
!Number.isInteger(candidate.payloadSchemaVersion) ||
|
|
276
|
+
candidate.payloadSchemaVersion < 1 ||
|
|
277
|
+
candidate.payloadSchemaVersion > 2_147_483_647
|
|
278
|
+
) {
|
|
279
|
+
throw new BadRequestException(
|
|
280
|
+
'payloadSchemaVersion must be a positive signed 32-bit integer.',
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
protected notFound(): never {
|
|
286
|
+
throw new NotFoundException('Omni resource was not found in this scope.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private scopedWhere(
|
|
290
|
+
where:
|
|
291
|
+
| FindOptionsWhere<Entity>[]
|
|
292
|
+
| FindOptionsWhere<Entity>
|
|
293
|
+
| ObjectLiteral
|
|
294
|
+
| string,
|
|
295
|
+
): FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[] {
|
|
296
|
+
if (typeof where === 'string') {
|
|
297
|
+
throw new BadRequestException(
|
|
298
|
+
'String where clauses are not supported for scoped Omni resources.',
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (Array.isArray(where)) {
|
|
302
|
+
return where.map((condition) => this.scopedConditions(condition));
|
|
303
|
+
}
|
|
304
|
+
return this.scopedConditions(where as FindOptionsWhere<Entity>);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private rejectScopeInWhere(where: unknown): void {
|
|
308
|
+
if (!where || typeof where !== 'object') return;
|
|
309
|
+
if (Array.isArray(where)) {
|
|
310
|
+
where.forEach((item) => this.rejectScopeInWhere(item));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const candidate = where as Record<string, unknown>;
|
|
314
|
+
if (hasOwn(candidate, 'scopeId')) this.rejectClientScope(candidate);
|
|
315
|
+
if (candidate.filters && typeof candidate.filters === 'object') {
|
|
316
|
+
this.rejectScopeInWhere(candidate.filters);
|
|
317
|
+
}
|
|
318
|
+
if (Array.isArray(candidate.childExpressions)) {
|
|
319
|
+
candidate.childExpressions.forEach((item) =>
|
|
320
|
+
this.rejectScopeInWhere(item),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isOmniScope(value: unknown): value is OmniScope {
|
|
327
|
+
return (
|
|
328
|
+
!!value &&
|
|
329
|
+
typeof value === 'object' &&
|
|
330
|
+
typeof (value as OmniScope).scopeId === 'string' &&
|
|
331
|
+
typeof (value as OmniScope).cacheKey === 'function'
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const defaultOmniScope: OmniScope = {
|
|
336
|
+
scopeId: 'default',
|
|
337
|
+
cacheKey: (key) => `default:${key}`,
|
|
338
|
+
};
|