@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,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
|
+
}
|