@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,610 +1,610 @@
1
- import { BadRequestException, ConflictException } from '@nestjs/common';
2
- import {
3
- Operators,
4
- PROJECTION_INTEGER_MAX,
5
- type CrudGenFindManyOptions,
6
- } from '@nest-yalc-2/crud-gen';
7
- import {
8
- In,
9
- IsNull,
10
- type DataSource,
11
- type DeepPartial,
12
- type EntityManager,
13
- type FindOptionsWhere,
14
- type ObjectLiteral,
15
- type Repository,
16
- } from 'typeorm';
17
- import { OmniRecordEntity } from './base/omni-record.entity.js';
18
- import { OmniRelationEntity } from './base/omni-relation.entity.js';
19
- import {
20
- createOmniProjectionReaderCatalog,
21
- type OmniProjectionReaderCatalog,
22
- } from './omni-projection.catalog.js';
23
- import {
24
- getOmniRelationProjectionAliases,
25
- getOmniRelationProjectionAllowedKinds,
26
- type OmniRelationProjectionDefinition,
27
- } from './omni-relation-projection.definition.js';
28
- import type { OmniProjectionLifecycle } from './omni-projection.lifecycle.js';
29
- import { OmniRelationService } from './omni-relation.service.js';
30
- import { OmniRelationStatus } from './omni-relation-status.enum.js';
31
- import type { OmniRelationKindContract } from './omni-relation-kind.contract.js';
32
- import type { OmniDeletePolicy, OmniScope } from './omni-scope.js';
33
-
34
- function hasOwn(input: object, key: string): boolean {
35
- return Object.prototype.hasOwnProperty.call(input, key);
36
- }
37
-
38
- function isRetryableTransactionError(error: unknown): boolean {
39
- const candidate = error as {
40
- code?: unknown;
41
- driverError?: { code?: unknown };
42
- };
43
- const code = candidate?.driverError?.code ?? candidate?.code;
44
- return (
45
- code === '40001' ||
46
- code === 'SQLITE_BUSY' ||
47
- code === 'SQLITE_BUSY_SNAPSHOT'
48
- );
49
- }
50
-
51
- /**
52
- * One generated relation surface over OmniRelationEntity. Creation selects an
53
- * allowed kind; every other operation constrains that field to the registered
54
- * set and keeps endpoints, status, and schema metadata server-owned.
55
- */
56
- export class OmniRelationProjectionService extends OmniRelationService {
57
- constructor(
58
- repository: Repository<OmniRelationEntity>,
59
- scope: OmniScope,
60
- private readonly relationDeletion: OmniDeletePolicy,
61
- recordRepository: Repository<OmniRecordEntity>,
62
- kinds: OmniRelationKindContract,
63
- private readonly definition: OmniRelationProjectionDefinition,
64
- private readonly dataSource?: DataSource,
65
- private readonly lifecycle?: OmniProjectionLifecycle<
66
- OmniRelationProjectionDefinition,
67
- OmniRelationEntity
68
- >,
69
- private readonly readerCatalog?: OmniProjectionReaderCatalog,
70
- ) {
71
- super(repository, scope, relationDeletion, recordRepository, kinds);
72
- for (const kind of getOmniRelationProjectionAllowedKinds(definition)) {
73
- try {
74
- kinds.assert(kind);
75
- } catch (error) {
76
- throw new TypeError(
77
- error instanceof Error
78
- ? error.message
79
- : 'Omni relation projection kind is not registered.',
80
- );
81
- }
82
- }
83
- }
84
-
85
- override async createEntity(
86
- input: DeepPartial<OmniRelationEntity>,
87
- findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
88
- returnEntity?: true,
89
- ): Promise<OmniRelationEntity>;
90
- override async createEntity(
91
- input: DeepPartial<OmniRelationEntity>,
92
- findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
93
- returnEntity?: boolean,
94
- ): Promise<OmniRelationEntity | boolean>;
95
- override async createEntity(
96
- input: DeepPartial<OmniRelationEntity>,
97
- findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
98
- returnEntity = true,
99
- ): Promise<OmniRelationEntity | boolean> {
100
- const normalized = this.normalizeInput(input as Record<string, unknown>);
101
- this.rejectFixedFields(normalized, true);
102
- this.selectCreateKind(normalized);
103
- const created = this.createValues(normalized);
104
- if (!this.lifecycle) {
105
- const result = await super.createEntity(
106
- created,
107
- findOptions,
108
- returnEntity,
109
- );
110
- return typeof result === 'boolean' ? result : this.publicEntity(result);
111
- }
112
- const result = await this.mutate(async (manager) => {
113
- await this.assertRelation(
114
- created,
115
- manager.getRepository(OmniRecordEntity),
116
- );
117
- await this.lifecycle!.beforeCreate?.({
118
- definition: this.definition,
119
- scope: this.scope,
120
- manager,
121
- readers: this.readers(manager),
122
- input: this.publicInput(normalized),
123
- });
124
- const repository = manager.getRepository(OmniRelationEntity);
125
- const entity = {
126
- ...created,
127
- scopeId: this.scopeId,
128
- };
129
- const guid = this.requiredIdentifier(entity.guid, 'guid');
130
- await repository.insert(entity as never);
131
- if (!returnEntity) return true;
132
- return repository.findOneOrFail({
133
- where: {
134
- scopeId: this.scopeId,
135
- guid,
136
- ...this.definitionFilters(),
137
- } as FindOptionsWhere<OmniRelationEntity>,
138
- });
139
- });
140
- return typeof result === 'boolean' ? result : this.publicEntity(result);
141
- }
142
-
143
- override async getEntity(
144
- conditions:
145
- | FindOptionsWhere<OmniRelationEntity>[]
146
- | FindOptionsWhere<OmniRelationEntity>
147
- | ObjectLiteral
148
- | string,
149
- fields?: (keyof OmniRelationEntity)[],
150
- relations?: string[],
151
- databaseName?: string,
152
- options?: { failOnNull: false },
153
- ): Promise<OmniRelationEntity | undefined>;
154
- override async getEntity(
155
- conditions:
156
- | FindOptionsWhere<OmniRelationEntity>[]
157
- | FindOptionsWhere<OmniRelationEntity>
158
- | ObjectLiteral
159
- | string,
160
- fields?: (keyof OmniRelationEntity)[],
161
- relations?: string[],
162
- databaseName?: string,
163
- options?: { failOnNull?: boolean },
164
- ): Promise<OmniRelationEntity>;
165
- override async getEntity(
166
- conditions:
167
- | FindOptionsWhere<OmniRelationEntity>[]
168
- | FindOptionsWhere<OmniRelationEntity>
169
- | ObjectLiteral
170
- | string,
171
- fields?: (keyof OmniRelationEntity)[],
172
- relations?: string[],
173
- databaseName?: string,
174
- options?: { failOnNull?: boolean },
175
- ): Promise<OmniRelationEntity | undefined> {
176
- const entity = await super.getEntity(
177
- this.withDefinitionConditions(conditions),
178
- fields,
179
- relations,
180
- databaseName,
181
- options as never,
182
- );
183
- return entity ? this.publicEntity(entity) : entity;
184
- }
185
-
186
- override async getEntityListExtended(
187
- findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
188
- withCount?: false,
189
- relations?: string[],
190
- databaseName?: string,
191
- ): Promise<OmniRelationEntity[]>;
192
- override async getEntityListExtended(
193
- findOptions: CrudGenFindManyOptions<OmniRelationEntity>,
194
- withCount: true,
195
- relations?: string[],
196
- databaseName?: string,
197
- ): Promise<[OmniRelationEntity[], number]>;
198
- override async getEntityListExtended(
199
- findOptions: CrudGenFindManyOptions<OmniRelationEntity> = {},
200
- withCount = false,
201
- relations?: string[],
202
- databaseName?: string,
203
- ): Promise<OmniRelationEntity[] | [OmniRelationEntity[], number]> {
204
- this.rejectFixedFilters(findOptions.where);
205
- const where = {
206
- operator: Operators.AND,
207
- filters: this.definitionFilters(),
208
- ...(findOptions.where ? { childExpressions: [findOptions.where] } : {}),
209
- };
210
- if (withCount) {
211
- const result = await super.getEntityListExtended(
212
- { ...findOptions, where },
213
- true,
214
- relations,
215
- databaseName,
216
- );
217
- return [result[0].map((entity) => this.publicEntity(entity)), result[1]];
218
- }
219
- const result = await super.getEntityListExtended(
220
- { ...findOptions, where },
221
- false,
222
- relations,
223
- databaseName,
224
- );
225
- return result.map((entity) => this.publicEntity(entity));
226
- }
227
-
228
- override async updateEntity(
229
- conditions: FindOptionsWhere<OmniRelationEntity>,
230
- input: DeepPartial<OmniRelationEntity>,
231
- _findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
232
- returnEntity?: true,
233
- ): Promise<OmniRelationEntity>;
234
- override async updateEntity(
235
- conditions: FindOptionsWhere<OmniRelationEntity>,
236
- input: DeepPartial<OmniRelationEntity>,
237
- _findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
238
- returnEntity?: boolean,
239
- ): Promise<OmniRelationEntity | boolean>;
240
- override async updateEntity(
241
- conditions: FindOptionsWhere<OmniRelationEntity>,
242
- input: DeepPartial<OmniRelationEntity>,
243
- _findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
244
- returnEntity = true,
245
- ): Promise<OmniRelationEntity | boolean> {
246
- const normalized = this.normalizeInput(input as Record<string, unknown>);
247
- this.rejectFixedFields(normalized);
248
- this.rejectImmutableEndpoints(normalized);
249
- this.validatePayload(normalized);
250
- const expectedRevision = normalized.expectedRevision;
251
- if (
252
- typeof expectedRevision !== 'number' ||
253
- !Number.isInteger(expectedRevision) ||
254
- expectedRevision < 1 ||
255
- expectedRevision >= PROJECTION_INTEGER_MAX
256
- ) {
257
- throw new BadRequestException(
258
- `expectedRevision must be an integer between 1 and ${PROJECTION_INTEGER_MAX - 1}.`,
259
- );
260
- }
261
- const fixedConditions = this.withDefinitionConditions(
262
- this.normalizeConditions(conditions),
263
- ) as FindOptionsWhere<OmniRelationEntity>;
264
- const { expectedRevision: _expectedRevision, ...changes } = normalized;
265
- if (Object.keys(changes).length === 0) {
266
- throw new BadRequestException('Omni relation update requires metadata.');
267
- }
268
- const result = await this.mutate(async (manager) => {
269
- const repository = manager.getRepository(OmniRelationEntity);
270
- const current = await repository.findOne({ where: fixedConditions });
271
- if (!current) this.notFound();
272
- if (current.revision !== expectedRevision) {
273
- throw new ConflictException('Omni relation revision conflict.');
274
- }
275
- await this.assertRelation(
276
- { ...current, ...changes },
277
- manager.getRepository(OmniRecordEntity),
278
- );
279
- await this.lifecycle?.beforeUpdate?.({
280
- definition: this.definition,
281
- scope: this.scope,
282
- manager,
283
- readers: this.readers(manager),
284
- input: this.publicInput(normalized),
285
- current,
286
- });
287
- const result = await repository
288
- .createQueryBuilder()
289
- .update()
290
- .set({ ...changes, revision: () => '"revision" + 1' } as never)
291
- .where('"scopeId" = :scopeId', { scopeId: this.scopeId })
292
- .andWhere('"guid" = :guid', { guid: current.guid })
293
- .andWhere('"revision" = :expectedRevision', { expectedRevision })
294
- .andWhere('"kind" IN (:...kinds)', {
295
- kinds: [...getOmniRelationProjectionAllowedKinds(this.definition)],
296
- })
297
- .andWhere('"status" = :status', {
298
- status: this.definition.relation.status ?? OmniRelationStatus.Active,
299
- })
300
- .andWhere(
301
- this.definition.relation.schema
302
- ? '"payloadSchemaId" = :schemaId AND "payloadSchemaVersion" = :schemaVersion'
303
- : '"payloadSchemaId" IS NULL AND "payloadSchemaVersion" IS NULL',
304
- this.definition.relation.schema
305
- ? {
306
- schemaId: this.definition.relation.schema.id,
307
- schemaVersion: this.definition.relation.schema.version,
308
- }
309
- : {},
310
- )
311
- .execute();
312
- if (result.affected !== 1) {
313
- const existing = await repository.findOne({ where: fixedConditions });
314
- if (!existing) this.notFound();
315
- throw new ConflictException('Omni relation revision conflict.');
316
- }
317
- if (!returnEntity) return true;
318
- return repository.findOneOrFail({ where: fixedConditions });
319
- });
320
- return typeof result === 'boolean' ? result : this.publicEntity(result);
321
- }
322
-
323
- override async deleteEntity(
324
- conditions: FindOptionsWhere<OmniRelationEntity>,
325
- ): Promise<boolean> {
326
- const fixedConditions = this.withDefinitionConditions(
327
- this.normalizeConditions(conditions),
328
- ) as FindOptionsWhere<OmniRelationEntity>;
329
- if (!this.lifecycle) return super.deleteEntity(fixedConditions);
330
- return this.mutate(async (manager) => {
331
- const repository = manager.getRepository(OmniRelationEntity);
332
- const current = await repository.findOne({ where: fixedConditions });
333
- if (!current) this.notFound();
334
- await this.lifecycle!.beforeDelete?.({
335
- definition: this.definition,
336
- scope: this.scope,
337
- manager,
338
- readers: this.readers(manager),
339
- input: {},
340
- current,
341
- });
342
- const result =
343
- this.relationDeletion === 'hard'
344
- ? await repository.delete(fixedConditions)
345
- : await repository.update(fixedConditions, { deletedAt: new Date() });
346
- if (result.affected !== 1) this.notFound();
347
- return true;
348
- });
349
- }
350
-
351
- protected override assertEndpointKinds(
352
- source: OmniRecordEntity,
353
- target: OmniRecordEntity,
354
- ): void {
355
- if (
356
- source.kind !== this.definition.relation.sourceKind ||
357
- target.kind !== this.definition.relation.targetKind
358
- ) {
359
- throw new BadRequestException(
360
- 'Omni relation endpoints do not match the registered resource kinds.',
361
- );
362
- }
363
- }
364
-
365
- private createValues(
366
- input: Record<string, unknown>,
367
- ): DeepPartial<OmniRelationEntity> {
368
- return {
369
- ...input,
370
- kind: input.kind as string,
371
- status: this.definition.relation.status ?? OmniRelationStatus.Active,
372
- ...(this.definition.relation.schema
373
- ? {
374
- payloadSchemaId: this.definition.relation.schema.id,
375
- payloadSchemaVersion: this.definition.relation.schema.version,
376
- }
377
- : {}),
378
- } as DeepPartial<OmniRelationEntity>;
379
- }
380
-
381
- private publicEntity(entity: OmniRelationEntity): OmniRelationEntity {
382
- const aliases = getOmniRelationProjectionAliases(this.definition);
383
- return {
384
- ...entity,
385
- ...(aliases.kind === 'kind' ? {} : { [aliases.kind]: entity.kind }),
386
- ...(aliases.source === 'sourceRecordId'
387
- ? {}
388
- : { [aliases.source]: entity.sourceRecordId }),
389
- ...(aliases.target === 'targetRecordId'
390
- ? {}
391
- : { [aliases.target]: entity.targetRecordId }),
392
- ...(aliases.payload === 'payload'
393
- ? {}
394
- : { [aliases.payload]: entity.payload }),
395
- } as OmniRelationEntity;
396
- }
397
-
398
- /** Keeps lifecycle policies transport-neutral by exposing definition aliases. */
399
- private publicInput(
400
- input: Readonly<Record<string, unknown>>,
401
- ): Readonly<Record<string, unknown>> {
402
- const aliases = getOmniRelationProjectionAliases(this.definition);
403
- const output = { ...input };
404
- for (const [field, alias] of [
405
- ['kind', aliases.kind],
406
- ['sourceRecordId', aliases.source],
407
- ['targetRecordId', aliases.target],
408
- ['payload', aliases.payload],
409
- ] as const) {
410
- if (field === alias || !hasOwn(output, field)) continue;
411
- output[alias] = output[field];
412
- delete output[field];
413
- }
414
- return output;
415
- }
416
-
417
- private selectCreateKind(input: Record<string, unknown>): void {
418
- const allowed = getOmniRelationProjectionAllowedKinds(this.definition);
419
- if (allowed.length === 1) {
420
- if (input.kind !== undefined) {
421
- throw new BadRequestException('Omni relation kind is server-owned.');
422
- }
423
- input.kind = allowed[0];
424
- return;
425
- }
426
- if (typeof input.kind !== 'string' || !allowed.includes(input.kind)) {
427
- throw new BadRequestException(
428
- 'Omni relation kind is not allowed by this resource.',
429
- );
430
- }
431
- }
432
-
433
- private normalizeInput(
434
- input: Record<string, unknown>,
435
- ): Record<string, unknown> {
436
- const normalized = { ...input };
437
- const aliases = getOmniRelationProjectionAliases(this.definition);
438
- for (const [alias, field] of [
439
- [aliases.kind, 'kind'],
440
- [aliases.source, 'sourceRecordId'],
441
- [aliases.target, 'targetRecordId'],
442
- [aliases.payload, 'payload'],
443
- ] as const) {
444
- if (alias === field) continue;
445
- if (!hasOwn(normalized, alias)) continue;
446
- if (hasOwn(normalized, field)) {
447
- throw new BadRequestException(
448
- `Omni relation ${alias} conflicts with ${field}.`,
449
- );
450
- }
451
- normalized[field] = normalized[alias];
452
- delete normalized[alias];
453
- }
454
- return normalized;
455
- }
456
-
457
- private normalizeConditions(
458
- conditions: FindOptionsWhere<OmniRelationEntity>,
459
- ): FindOptionsWhere<OmniRelationEntity> {
460
- return this.normalizeInput(
461
- conditions as Record<string, unknown>,
462
- ) as FindOptionsWhere<OmniRelationEntity>;
463
- }
464
-
465
- private rejectFixedFields(
466
- input: Record<string, unknown>,
467
- allowKind = false,
468
- ): void {
469
- for (const field of [
470
- ...(allowKind ? [] : ['kind']),
471
- 'status',
472
- 'payloadSchemaId',
473
- 'payloadSchemaVersion',
474
- ]) {
475
- if (hasOwn(input, field)) {
476
- throw new BadRequestException(
477
- `Omni relation ${field} is server-owned.`,
478
- );
479
- }
480
- }
481
- }
482
-
483
- private rejectImmutableEndpoints(input: Record<string, unknown>): void {
484
- for (const field of ['guid', 'sourceRecordId', 'targetRecordId']) {
485
- if (hasOwn(input, field)) {
486
- throw new BadRequestException(`Omni relation ${field} is immutable.`);
487
- }
488
- }
489
- }
490
-
491
- private validatePayload(input: Record<string, unknown>): void {
492
- if (
493
- hasOwn(input, 'payload') &&
494
- input.payload !== null &&
495
- (typeof input.payload !== 'object' || Array.isArray(input.payload))
496
- ) {
497
- throw new BadRequestException('payload must be a JSON object or null.');
498
- }
499
- }
500
-
501
- private withDefinitionConditions(
502
- conditions:
503
- | FindOptionsWhere<OmniRelationEntity>[]
504
- | FindOptionsWhere<OmniRelationEntity>
505
- | ObjectLiteral
506
- | string,
507
- ):
508
- | FindOptionsWhere<OmniRelationEntity>[]
509
- | FindOptionsWhere<OmniRelationEntity>
510
- | ObjectLiteral {
511
- if (typeof conditions === 'string') {
512
- return { guid: conditions, ...this.definitionFilters() };
513
- }
514
- if (Array.isArray(conditions)) {
515
- return conditions.map(
516
- (condition) =>
517
- this.withDefinitionConditions(
518
- condition,
519
- ) as FindOptionsWhere<OmniRelationEntity>,
520
- );
521
- }
522
- this.assertFixedConditions(conditions);
523
- return { ...conditions, ...this.definitionFilters() };
524
- }
525
-
526
- private definitionFilters(): Record<string, unknown> {
527
- const relation = this.definition.relation;
528
- return {
529
- kind: In([...getOmniRelationProjectionAllowedKinds(this.definition)]),
530
- status: relation.status ?? OmniRelationStatus.Active,
531
- ...(relation.schema
532
- ? {
533
- payloadSchemaId: relation.schema.id,
534
- payloadSchemaVersion: relation.schema.version,
535
- }
536
- : {
537
- payloadSchemaId: IsNull(),
538
- payloadSchemaVersion: IsNull(),
539
- }),
540
- };
541
- }
542
-
543
- private assertFixedConditions(conditions: ObjectLiteral): void {
544
- for (const field of Object.keys(this.definitionFilters())) {
545
- if (hasOwn(conditions, field)) {
546
- throw new BadRequestException(
547
- `Omni relation ${field} is server-owned.`,
548
- );
549
- }
550
- }
551
- }
552
-
553
- private rejectFixedFilters(where: unknown): void {
554
- if (!where || typeof where !== 'object') return;
555
- if (Array.isArray(where)) {
556
- where.forEach((entry) => this.rejectFixedFilters(entry));
557
- return;
558
- }
559
- const candidate = where as Record<string, unknown>;
560
- for (const field of Object.keys(this.definitionFilters())) {
561
- if (hasOwn(candidate, field)) {
562
- throw new BadRequestException(
563
- `Omni relation ${field} is server-owned.`,
564
- );
565
- }
566
- }
567
- if (candidate.filters && typeof candidate.filters === 'object') {
568
- this.rejectFixedFilters(candidate.filters);
569
- }
570
- if (Array.isArray(candidate.childExpressions)) {
571
- candidate.childExpressions.forEach((entry) =>
572
- this.rejectFixedFilters(entry),
573
- );
574
- }
575
- }
576
-
577
- private readers(manager: EntityManager) {
578
- return (
579
- this.readerCatalog ??
580
- createOmniProjectionReaderCatalog([
581
- {
582
- type: 'relation',
583
- id: this.definition.id,
584
- definition: this.definition,
585
- },
586
- ])
587
- ).bind(manager, this.scope);
588
- }
589
-
590
- private async mutate<T>(
591
- work: (manager: EntityManager) => Promise<T>,
592
- ): Promise<T> {
593
- if (!this.lifecycle) return work(this.getRepositoryWrite().manager);
594
- if (!this.dataSource) {
595
- throw new TypeError(
596
- 'Omni relation lifecycle requires a DataSource for serializable mutations.',
597
- );
598
- }
599
- try {
600
- return await this.dataSource.transaction('SERIALIZABLE', work);
601
- } catch (error) {
602
- if (isRetryableTransactionError(error)) {
603
- throw new ConflictException(
604
- 'Omni projection concurrent write conflict; retry the mutation.',
605
- );
606
- }
607
- throw error;
608
- }
609
- }
610
- }
1
+ import { BadRequestException, ConflictException } from '@nestjs/common';
2
+ import {
3
+ Operators,
4
+ PROJECTION_INTEGER_MAX,
5
+ type CrudGenFindManyOptions,
6
+ } from '@nest-yalc-2/crud-gen';
7
+ import {
8
+ In,
9
+ IsNull,
10
+ type DataSource,
11
+ type DeepPartial,
12
+ type EntityManager,
13
+ type FindOptionsWhere,
14
+ type ObjectLiteral,
15
+ type Repository,
16
+ } from 'typeorm';
17
+ import { OmniRecordEntity } from './base/omni-record.entity.js';
18
+ import { OmniRelationEntity } from './base/omni-relation.entity.js';
19
+ import {
20
+ createOmniProjectionReaderCatalog,
21
+ type OmniProjectionReaderCatalog,
22
+ } from './omni-projection.catalog.js';
23
+ import {
24
+ getOmniRelationProjectionAliases,
25
+ getOmniRelationProjectionAllowedKinds,
26
+ type OmniRelationProjectionDefinition,
27
+ } from './omni-relation-projection.definition.js';
28
+ import type { OmniProjectionLifecycle } from './omni-projection.lifecycle.js';
29
+ import { OmniRelationService } from './omni-relation.service.js';
30
+ import { OmniRelationStatus } from './omni-relation-status.enum.js';
31
+ import type { OmniRelationKindContract } from './omni-relation-kind.contract.js';
32
+ import type { OmniDeletePolicy, OmniScope } from './omni-scope.js';
33
+
34
+ function hasOwn(input: object, key: string): boolean {
35
+ return Object.prototype.hasOwnProperty.call(input, key);
36
+ }
37
+
38
+ function isRetryableTransactionError(error: unknown): boolean {
39
+ const candidate = error as {
40
+ code?: unknown;
41
+ driverError?: { code?: unknown };
42
+ };
43
+ const code = candidate?.driverError?.code ?? candidate?.code;
44
+ return (
45
+ code === '40001' ||
46
+ code === 'SQLITE_BUSY' ||
47
+ code === 'SQLITE_BUSY_SNAPSHOT'
48
+ );
49
+ }
50
+
51
+ /**
52
+ * One generated relation surface over OmniRelationEntity. Creation selects an
53
+ * allowed kind; every other operation constrains that field to the registered
54
+ * set and keeps endpoints, status, and schema metadata server-owned.
55
+ */
56
+ export class OmniRelationProjectionService extends OmniRelationService {
57
+ constructor(
58
+ repository: Repository<OmniRelationEntity>,
59
+ scope: OmniScope,
60
+ private readonly relationDeletion: OmniDeletePolicy,
61
+ recordRepository: Repository<OmniRecordEntity>,
62
+ kinds: OmniRelationKindContract,
63
+ private readonly definition: OmniRelationProjectionDefinition,
64
+ private readonly dataSource?: DataSource,
65
+ private readonly lifecycle?: OmniProjectionLifecycle<
66
+ OmniRelationProjectionDefinition,
67
+ OmniRelationEntity
68
+ >,
69
+ private readonly readerCatalog?: OmniProjectionReaderCatalog,
70
+ ) {
71
+ super(repository, scope, relationDeletion, recordRepository, kinds);
72
+ for (const kind of getOmniRelationProjectionAllowedKinds(definition)) {
73
+ try {
74
+ kinds.assert(kind);
75
+ } catch (error) {
76
+ throw new TypeError(
77
+ error instanceof Error
78
+ ? error.message
79
+ : 'Omni relation projection kind is not registered.',
80
+ );
81
+ }
82
+ }
83
+ }
84
+
85
+ override async createEntity(
86
+ input: DeepPartial<OmniRelationEntity>,
87
+ findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
88
+ returnEntity?: true,
89
+ ): Promise<OmniRelationEntity>;
90
+ override async createEntity(
91
+ input: DeepPartial<OmniRelationEntity>,
92
+ findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
93
+ returnEntity?: boolean,
94
+ ): Promise<OmniRelationEntity | boolean>;
95
+ override async createEntity(
96
+ input: DeepPartial<OmniRelationEntity>,
97
+ findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
98
+ returnEntity = true,
99
+ ): Promise<OmniRelationEntity | boolean> {
100
+ const normalized = this.normalizeInput(input as Record<string, unknown>);
101
+ this.rejectFixedFields(normalized, true);
102
+ this.selectCreateKind(normalized);
103
+ const created = this.createValues(normalized);
104
+ if (!this.lifecycle) {
105
+ const result = await super.createEntity(
106
+ created,
107
+ findOptions,
108
+ returnEntity,
109
+ );
110
+ return typeof result === 'boolean' ? result : this.publicEntity(result);
111
+ }
112
+ const result = await this.mutate(async (manager) => {
113
+ await this.assertRelation(
114
+ created,
115
+ manager.getRepository(OmniRecordEntity),
116
+ );
117
+ await this.lifecycle!.beforeCreate?.({
118
+ definition: this.definition,
119
+ scope: this.scope,
120
+ manager,
121
+ readers: this.readers(manager),
122
+ input: this.publicInput(normalized),
123
+ });
124
+ const repository = manager.getRepository(OmniRelationEntity);
125
+ const entity = {
126
+ ...created,
127
+ scopeId: this.scopeId,
128
+ };
129
+ const guid = this.requiredIdentifier(entity.guid, 'guid');
130
+ await repository.insert(entity as never);
131
+ if (!returnEntity) return true;
132
+ return repository.findOneOrFail({
133
+ where: {
134
+ scopeId: this.scopeId,
135
+ guid,
136
+ ...this.definitionFilters(),
137
+ } as FindOptionsWhere<OmniRelationEntity>,
138
+ });
139
+ });
140
+ return typeof result === 'boolean' ? result : this.publicEntity(result);
141
+ }
142
+
143
+ override async getEntity(
144
+ conditions:
145
+ | FindOptionsWhere<OmniRelationEntity>[]
146
+ | FindOptionsWhere<OmniRelationEntity>
147
+ | ObjectLiteral
148
+ | string,
149
+ fields?: (keyof OmniRelationEntity)[],
150
+ relations?: string[],
151
+ databaseName?: string,
152
+ options?: { failOnNull: false },
153
+ ): Promise<OmniRelationEntity | undefined>;
154
+ override async getEntity(
155
+ conditions:
156
+ | FindOptionsWhere<OmniRelationEntity>[]
157
+ | FindOptionsWhere<OmniRelationEntity>
158
+ | ObjectLiteral
159
+ | string,
160
+ fields?: (keyof OmniRelationEntity)[],
161
+ relations?: string[],
162
+ databaseName?: string,
163
+ options?: { failOnNull?: boolean },
164
+ ): Promise<OmniRelationEntity>;
165
+ override async getEntity(
166
+ conditions:
167
+ | FindOptionsWhere<OmniRelationEntity>[]
168
+ | FindOptionsWhere<OmniRelationEntity>
169
+ | ObjectLiteral
170
+ | string,
171
+ fields?: (keyof OmniRelationEntity)[],
172
+ relations?: string[],
173
+ databaseName?: string,
174
+ options?: { failOnNull?: boolean },
175
+ ): Promise<OmniRelationEntity | undefined> {
176
+ const entity = await super.getEntity(
177
+ this.withDefinitionConditions(conditions),
178
+ fields,
179
+ relations,
180
+ databaseName,
181
+ options as never,
182
+ );
183
+ return entity ? this.publicEntity(entity) : entity;
184
+ }
185
+
186
+ override async getEntityListExtended(
187
+ findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
188
+ withCount?: false,
189
+ relations?: string[],
190
+ databaseName?: string,
191
+ ): Promise<OmniRelationEntity[]>;
192
+ override async getEntityListExtended(
193
+ findOptions: CrudGenFindManyOptions<OmniRelationEntity>,
194
+ withCount: true,
195
+ relations?: string[],
196
+ databaseName?: string,
197
+ ): Promise<[OmniRelationEntity[], number]>;
198
+ override async getEntityListExtended(
199
+ findOptions: CrudGenFindManyOptions<OmniRelationEntity> = {},
200
+ withCount = false,
201
+ relations?: string[],
202
+ databaseName?: string,
203
+ ): Promise<OmniRelationEntity[] | [OmniRelationEntity[], number]> {
204
+ this.rejectFixedFilters(findOptions.where);
205
+ const where = {
206
+ operator: Operators.AND,
207
+ filters: this.definitionFilters(),
208
+ ...(findOptions.where ? { childExpressions: [findOptions.where] } : {}),
209
+ };
210
+ if (withCount) {
211
+ const result = await super.getEntityListExtended(
212
+ { ...findOptions, where },
213
+ true,
214
+ relations,
215
+ databaseName,
216
+ );
217
+ return [result[0].map((entity) => this.publicEntity(entity)), result[1]];
218
+ }
219
+ const result = await super.getEntityListExtended(
220
+ { ...findOptions, where },
221
+ false,
222
+ relations,
223
+ databaseName,
224
+ );
225
+ return result.map((entity) => this.publicEntity(entity));
226
+ }
227
+
228
+ override async updateEntity(
229
+ conditions: FindOptionsWhere<OmniRelationEntity>,
230
+ input: DeepPartial<OmniRelationEntity>,
231
+ _findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
232
+ returnEntity?: true,
233
+ ): Promise<OmniRelationEntity>;
234
+ override async updateEntity(
235
+ conditions: FindOptionsWhere<OmniRelationEntity>,
236
+ input: DeepPartial<OmniRelationEntity>,
237
+ _findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
238
+ returnEntity?: boolean,
239
+ ): Promise<OmniRelationEntity | boolean>;
240
+ override async updateEntity(
241
+ conditions: FindOptionsWhere<OmniRelationEntity>,
242
+ input: DeepPartial<OmniRelationEntity>,
243
+ _findOptions?: CrudGenFindManyOptions<OmniRelationEntity>,
244
+ returnEntity = true,
245
+ ): Promise<OmniRelationEntity | boolean> {
246
+ const normalized = this.normalizeInput(input as Record<string, unknown>);
247
+ this.rejectFixedFields(normalized);
248
+ this.rejectImmutableEndpoints(normalized);
249
+ this.validatePayload(normalized);
250
+ const expectedRevision = normalized.expectedRevision;
251
+ if (
252
+ typeof expectedRevision !== 'number' ||
253
+ !Number.isInteger(expectedRevision) ||
254
+ expectedRevision < 1 ||
255
+ expectedRevision >= PROJECTION_INTEGER_MAX
256
+ ) {
257
+ throw new BadRequestException(
258
+ `expectedRevision must be an integer between 1 and ${PROJECTION_INTEGER_MAX - 1}.`,
259
+ );
260
+ }
261
+ const fixedConditions = this.withDefinitionConditions(
262
+ this.normalizeConditions(conditions),
263
+ ) as FindOptionsWhere<OmniRelationEntity>;
264
+ const { expectedRevision: _expectedRevision, ...changes } = normalized;
265
+ if (Object.keys(changes).length === 0) {
266
+ throw new BadRequestException('Omni relation update requires metadata.');
267
+ }
268
+ const result = await this.mutate(async (manager) => {
269
+ const repository = manager.getRepository(OmniRelationEntity);
270
+ const current = await repository.findOne({ where: fixedConditions });
271
+ if (!current) this.notFound();
272
+ if (current.revision !== expectedRevision) {
273
+ throw new ConflictException('Omni relation revision conflict.');
274
+ }
275
+ await this.assertRelation(
276
+ { ...current, ...changes },
277
+ manager.getRepository(OmniRecordEntity),
278
+ );
279
+ await this.lifecycle?.beforeUpdate?.({
280
+ definition: this.definition,
281
+ scope: this.scope,
282
+ manager,
283
+ readers: this.readers(manager),
284
+ input: this.publicInput(normalized),
285
+ current,
286
+ });
287
+ const result = await repository
288
+ .createQueryBuilder()
289
+ .update()
290
+ .set({ ...changes, revision: () => '"revision" + 1' } as never)
291
+ .where('"scopeId" = :scopeId', { scopeId: this.scopeId })
292
+ .andWhere('"guid" = :guid', { guid: current.guid })
293
+ .andWhere('"revision" = :expectedRevision', { expectedRevision })
294
+ .andWhere('"kind" IN (:...kinds)', {
295
+ kinds: [...getOmniRelationProjectionAllowedKinds(this.definition)],
296
+ })
297
+ .andWhere('"status" = :status', {
298
+ status: this.definition.relation.status ?? OmniRelationStatus.Active,
299
+ })
300
+ .andWhere(
301
+ this.definition.relation.schema
302
+ ? '"payloadSchemaId" = :schemaId AND "payloadSchemaVersion" = :schemaVersion'
303
+ : '"payloadSchemaId" IS NULL AND "payloadSchemaVersion" IS NULL',
304
+ this.definition.relation.schema
305
+ ? {
306
+ schemaId: this.definition.relation.schema.id,
307
+ schemaVersion: this.definition.relation.schema.version,
308
+ }
309
+ : {},
310
+ )
311
+ .execute();
312
+ if (result.affected !== 1) {
313
+ const existing = await repository.findOne({ where: fixedConditions });
314
+ if (!existing) this.notFound();
315
+ throw new ConflictException('Omni relation revision conflict.');
316
+ }
317
+ if (!returnEntity) return true;
318
+ return repository.findOneOrFail({ where: fixedConditions });
319
+ });
320
+ return typeof result === 'boolean' ? result : this.publicEntity(result);
321
+ }
322
+
323
+ override async deleteEntity(
324
+ conditions: FindOptionsWhere<OmniRelationEntity>,
325
+ ): Promise<boolean> {
326
+ const fixedConditions = this.withDefinitionConditions(
327
+ this.normalizeConditions(conditions),
328
+ ) as FindOptionsWhere<OmniRelationEntity>;
329
+ if (!this.lifecycle) return super.deleteEntity(fixedConditions);
330
+ return this.mutate(async (manager) => {
331
+ const repository = manager.getRepository(OmniRelationEntity);
332
+ const current = await repository.findOne({ where: fixedConditions });
333
+ if (!current) this.notFound();
334
+ await this.lifecycle!.beforeDelete?.({
335
+ definition: this.definition,
336
+ scope: this.scope,
337
+ manager,
338
+ readers: this.readers(manager),
339
+ input: {},
340
+ current,
341
+ });
342
+ const result =
343
+ this.relationDeletion === 'hard'
344
+ ? await repository.delete(fixedConditions)
345
+ : await repository.update(fixedConditions, { deletedAt: new Date() });
346
+ if (result.affected !== 1) this.notFound();
347
+ return true;
348
+ });
349
+ }
350
+
351
+ protected override assertEndpointKinds(
352
+ source: OmniRecordEntity,
353
+ target: OmniRecordEntity,
354
+ ): void {
355
+ if (
356
+ source.kind !== this.definition.relation.sourceKind ||
357
+ target.kind !== this.definition.relation.targetKind
358
+ ) {
359
+ throw new BadRequestException(
360
+ 'Omni relation endpoints do not match the registered resource kinds.',
361
+ );
362
+ }
363
+ }
364
+
365
+ private createValues(
366
+ input: Record<string, unknown>,
367
+ ): DeepPartial<OmniRelationEntity> {
368
+ return {
369
+ ...input,
370
+ kind: input.kind as string,
371
+ status: this.definition.relation.status ?? OmniRelationStatus.Active,
372
+ ...(this.definition.relation.schema
373
+ ? {
374
+ payloadSchemaId: this.definition.relation.schema.id,
375
+ payloadSchemaVersion: this.definition.relation.schema.version,
376
+ }
377
+ : {}),
378
+ } as DeepPartial<OmniRelationEntity>;
379
+ }
380
+
381
+ private publicEntity(entity: OmniRelationEntity): OmniRelationEntity {
382
+ const aliases = getOmniRelationProjectionAliases(this.definition);
383
+ return {
384
+ ...entity,
385
+ ...(aliases.kind === 'kind' ? {} : { [aliases.kind]: entity.kind }),
386
+ ...(aliases.source === 'sourceRecordId'
387
+ ? {}
388
+ : { [aliases.source]: entity.sourceRecordId }),
389
+ ...(aliases.target === 'targetRecordId'
390
+ ? {}
391
+ : { [aliases.target]: entity.targetRecordId }),
392
+ ...(aliases.payload === 'payload'
393
+ ? {}
394
+ : { [aliases.payload]: entity.payload }),
395
+ } as OmniRelationEntity;
396
+ }
397
+
398
+ /** Keeps lifecycle policies transport-neutral by exposing definition aliases. */
399
+ private publicInput(
400
+ input: Readonly<Record<string, unknown>>,
401
+ ): Readonly<Record<string, unknown>> {
402
+ const aliases = getOmniRelationProjectionAliases(this.definition);
403
+ const output = { ...input };
404
+ for (const [field, alias] of [
405
+ ['kind', aliases.kind],
406
+ ['sourceRecordId', aliases.source],
407
+ ['targetRecordId', aliases.target],
408
+ ['payload', aliases.payload],
409
+ ] as const) {
410
+ if (field === alias || !hasOwn(output, field)) continue;
411
+ output[alias] = output[field];
412
+ delete output[field];
413
+ }
414
+ return output;
415
+ }
416
+
417
+ private selectCreateKind(input: Record<string, unknown>): void {
418
+ const allowed = getOmniRelationProjectionAllowedKinds(this.definition);
419
+ if (allowed.length === 1) {
420
+ if (input.kind !== undefined) {
421
+ throw new BadRequestException('Omni relation kind is server-owned.');
422
+ }
423
+ input.kind = allowed[0];
424
+ return;
425
+ }
426
+ if (typeof input.kind !== 'string' || !allowed.includes(input.kind)) {
427
+ throw new BadRequestException(
428
+ 'Omni relation kind is not allowed by this resource.',
429
+ );
430
+ }
431
+ }
432
+
433
+ private normalizeInput(
434
+ input: Record<string, unknown>,
435
+ ): Record<string, unknown> {
436
+ const normalized = { ...input };
437
+ const aliases = getOmniRelationProjectionAliases(this.definition);
438
+ for (const [alias, field] of [
439
+ [aliases.kind, 'kind'],
440
+ [aliases.source, 'sourceRecordId'],
441
+ [aliases.target, 'targetRecordId'],
442
+ [aliases.payload, 'payload'],
443
+ ] as const) {
444
+ if (alias === field) continue;
445
+ if (!hasOwn(normalized, alias)) continue;
446
+ if (hasOwn(normalized, field)) {
447
+ throw new BadRequestException(
448
+ `Omni relation ${alias} conflicts with ${field}.`,
449
+ );
450
+ }
451
+ normalized[field] = normalized[alias];
452
+ delete normalized[alias];
453
+ }
454
+ return normalized;
455
+ }
456
+
457
+ private normalizeConditions(
458
+ conditions: FindOptionsWhere<OmniRelationEntity>,
459
+ ): FindOptionsWhere<OmniRelationEntity> {
460
+ return this.normalizeInput(
461
+ conditions as Record<string, unknown>,
462
+ ) as FindOptionsWhere<OmniRelationEntity>;
463
+ }
464
+
465
+ private rejectFixedFields(
466
+ input: Record<string, unknown>,
467
+ allowKind = false,
468
+ ): void {
469
+ for (const field of [
470
+ ...(allowKind ? [] : ['kind']),
471
+ 'status',
472
+ 'payloadSchemaId',
473
+ 'payloadSchemaVersion',
474
+ ]) {
475
+ if (hasOwn(input, field)) {
476
+ throw new BadRequestException(
477
+ `Omni relation ${field} is server-owned.`,
478
+ );
479
+ }
480
+ }
481
+ }
482
+
483
+ private rejectImmutableEndpoints(input: Record<string, unknown>): void {
484
+ for (const field of ['guid', 'sourceRecordId', 'targetRecordId']) {
485
+ if (hasOwn(input, field)) {
486
+ throw new BadRequestException(`Omni relation ${field} is immutable.`);
487
+ }
488
+ }
489
+ }
490
+
491
+ private validatePayload(input: Record<string, unknown>): void {
492
+ if (
493
+ hasOwn(input, 'payload') &&
494
+ input.payload !== null &&
495
+ (typeof input.payload !== 'object' || Array.isArray(input.payload))
496
+ ) {
497
+ throw new BadRequestException('payload must be a JSON object or null.');
498
+ }
499
+ }
500
+
501
+ private withDefinitionConditions(
502
+ conditions:
503
+ | FindOptionsWhere<OmniRelationEntity>[]
504
+ | FindOptionsWhere<OmniRelationEntity>
505
+ | ObjectLiteral
506
+ | string,
507
+ ):
508
+ | FindOptionsWhere<OmniRelationEntity>[]
509
+ | FindOptionsWhere<OmniRelationEntity>
510
+ | ObjectLiteral {
511
+ if (typeof conditions === 'string') {
512
+ return { guid: conditions, ...this.definitionFilters() };
513
+ }
514
+ if (Array.isArray(conditions)) {
515
+ return conditions.map(
516
+ (condition) =>
517
+ this.withDefinitionConditions(
518
+ condition,
519
+ ) as FindOptionsWhere<OmniRelationEntity>,
520
+ );
521
+ }
522
+ this.assertFixedConditions(conditions);
523
+ return { ...conditions, ...this.definitionFilters() };
524
+ }
525
+
526
+ private definitionFilters(): Record<string, unknown> {
527
+ const relation = this.definition.relation;
528
+ return {
529
+ kind: In([...getOmniRelationProjectionAllowedKinds(this.definition)]),
530
+ status: relation.status ?? OmniRelationStatus.Active,
531
+ ...(relation.schema
532
+ ? {
533
+ payloadSchemaId: relation.schema.id,
534
+ payloadSchemaVersion: relation.schema.version,
535
+ }
536
+ : {
537
+ payloadSchemaId: IsNull(),
538
+ payloadSchemaVersion: IsNull(),
539
+ }),
540
+ };
541
+ }
542
+
543
+ private assertFixedConditions(conditions: ObjectLiteral): void {
544
+ for (const field of Object.keys(this.definitionFilters())) {
545
+ if (hasOwn(conditions, field)) {
546
+ throw new BadRequestException(
547
+ `Omni relation ${field} is server-owned.`,
548
+ );
549
+ }
550
+ }
551
+ }
552
+
553
+ private rejectFixedFilters(where: unknown): void {
554
+ if (!where || typeof where !== 'object') return;
555
+ if (Array.isArray(where)) {
556
+ where.forEach((entry) => this.rejectFixedFilters(entry));
557
+ return;
558
+ }
559
+ const candidate = where as Record<string, unknown>;
560
+ for (const field of Object.keys(this.definitionFilters())) {
561
+ if (hasOwn(candidate, field)) {
562
+ throw new BadRequestException(
563
+ `Omni relation ${field} is server-owned.`,
564
+ );
565
+ }
566
+ }
567
+ if (candidate.filters && typeof candidate.filters === 'object') {
568
+ this.rejectFixedFilters(candidate.filters);
569
+ }
570
+ if (Array.isArray(candidate.childExpressions)) {
571
+ candidate.childExpressions.forEach((entry) =>
572
+ this.rejectFixedFilters(entry),
573
+ );
574
+ }
575
+ }
576
+
577
+ private readers(manager: EntityManager) {
578
+ return (
579
+ this.readerCatalog ??
580
+ createOmniProjectionReaderCatalog([
581
+ {
582
+ type: 'relation',
583
+ id: this.definition.id,
584
+ definition: this.definition,
585
+ },
586
+ ])
587
+ ).bind(manager, this.scope);
588
+ }
589
+
590
+ private async mutate<T>(
591
+ work: (manager: EntityManager) => Promise<T>,
592
+ ): Promise<T> {
593
+ if (!this.lifecycle) return work(this.getRepositoryWrite().manager);
594
+ if (!this.dataSource) {
595
+ throw new TypeError(
596
+ 'Omni relation lifecycle requires a DataSource for serializable mutations.',
597
+ );
598
+ }
599
+ try {
600
+ return await this.dataSource.transaction('SERIALIZABLE', work);
601
+ } catch (error) {
602
+ if (isRetryableTransactionError(error)) {
603
+ throw new ConflictException(
604
+ 'Omni projection concurrent write conflict; retry the mutation.',
605
+ );
606
+ }
607
+ throw error;
608
+ }
609
+ }
610
+ }