@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.
Files changed (74) hide show
  1. package/package.json +17 -15
  2. package/src/__tests__/omni-collection.entity.spec.ts +49 -49
  3. package/src/__tests__/omni-collection.service.spec.ts +83 -83
  4. package/src/__tests__/omni-document.entity.spec.ts +55 -55
  5. package/src/__tests__/omni-document.service.spec.ts +83 -83
  6. package/src/__tests__/omni-extension-projection-constraints.spec.ts +518 -518
  7. package/src/__tests__/omni-extension-projection.registration.spec.ts +947 -946
  8. package/src/__tests__/omni-extension-projection.service.spec.ts +806 -806
  9. package/src/__tests__/omni-external-ref.entity.spec.ts +36 -36
  10. package/src/__tests__/omni-external-ref.service.spec.ts +252 -252
  11. package/src/__tests__/omni-named.entity.spec.ts +33 -33
  12. package/src/__tests__/omni-record.entity.spec.ts +32 -32
  13. package/src/__tests__/omni-record.service.spec.ts +107 -107
  14. package/src/__tests__/omni-relation-semantics.spec.ts +102 -102
  15. package/src/__tests__/omni-relation.entity.spec.ts +38 -38
  16. package/src/__tests__/omni-scope-substrate.spec.ts +81 -81
  17. package/src/__tests__/omni-scoped.backend.spec.ts +115 -115
  18. package/src/__tests__/omni-scoped.service.spec.ts +354 -354
  19. package/src/__tests__/omnikernel.diagnostics.spec.ts +94 -94
  20. package/src/__tests__/omnikernel.module.spec.ts +38 -40
  21. package/src/__tests__/omnikernel.persistence.spec.ts +154 -154
  22. package/src/__tests__/omnikernel.public-api.spec.ts +238 -238
  23. package/src/__tests__/omnikernel.query.service.spec.ts +155 -155
  24. package/src/base/omni-base.entity.ts +41 -41
  25. package/src/base/omni-external-ref.entity.ts +53 -53
  26. package/src/base/omni-named.entity.ts +16 -16
  27. package/src/base/omni-record.entity.ts +51 -51
  28. package/src/base/omni-relation.entity.ts +72 -72
  29. package/src/index.ts +49 -49
  30. package/src/omni-collection-kind.enum.ts +10 -10
  31. package/src/omni-collection.backend.ts +16 -16
  32. package/src/omni-collection.dto.ts +170 -170
  33. package/src/omni-collection.entity.ts +21 -21
  34. package/src/omni-collection.service.ts +75 -75
  35. package/src/omni-document-kind.enum.ts +12 -12
  36. package/src/omni-document.backend.ts +16 -16
  37. package/src/omni-document.dto.ts +198 -198
  38. package/src/omni-document.entity.ts +30 -30
  39. package/src/omni-document.service.ts +75 -75
  40. package/src/omni-dto.helpers.ts +21 -21
  41. package/src/omni-extension-projection.definition.ts +294 -294
  42. package/src/omni-extension-projection.resource.ts +274 -274
  43. package/src/omni-extension-projection.service.ts +452 -452
  44. package/src/omni-external-ref-binding.validator.ts +53 -53
  45. package/src/omni-external-ref-internal-type.enum.ts +11 -11
  46. package/src/omni-external-ref.backend.ts +25 -25
  47. package/src/omni-external-ref.dto.ts +128 -128
  48. package/src/omni-external-ref.service.ts +255 -255
  49. package/src/omni-migration.ts +365 -365
  50. package/src/omni-named.backend.ts +11 -11
  51. package/src/omni-named.dto.ts +86 -86
  52. package/src/omni-projection.catalog.ts +345 -345
  53. package/src/omni-projection.lifecycle.ts +53 -53
  54. package/src/omni-record-status.enum.ts +11 -11
  55. package/src/omni-record.backend.ts +19 -19
  56. package/src/omni-record.dto.ts +157 -157
  57. package/src/omni-record.service.ts +113 -113
  58. package/src/omni-relation-kind.contract.ts +49 -49
  59. package/src/omni-relation-kind.enum.ts +12 -12
  60. package/src/omni-relation-projection.definition.ts +166 -166
  61. package/src/omni-relation-projection.resource.ts +360 -360
  62. package/src/omni-relation-projection.service.ts +610 -610
  63. package/src/omni-relation-semantics.ts +52 -52
  64. package/src/omni-relation-status.enum.ts +11 -11
  65. package/src/omni-relation.backend.ts +21 -21
  66. package/src/omni-relation.dto.ts +142 -142
  67. package/src/omni-relation.service.ts +159 -159
  68. package/src/omni-scope.ts +161 -161
  69. package/src/omni-scoped.backend.ts +110 -110
  70. package/src/omni-scoped.repository.ts +34 -34
  71. package/src/omni-scoped.service.ts +338 -338
  72. package/src/omnikernel.diagnostics.ts +83 -83
  73. package/src/omnikernel.module.ts +97 -97
  74. 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
+ };