@nest-yalc-2/omnikernel-module 2.2.2 → 2.2.6
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,806 +1,806 @@
|
|
|
1
|
-
import {
|
|
2
|
-
afterEach,
|
|
3
|
-
beforeEach,
|
|
4
|
-
describe,
|
|
5
|
-
expect,
|
|
6
|
-
it,
|
|
7
|
-
jest,
|
|
8
|
-
} from "@jest/globals";
|
|
9
|
-
import {
|
|
10
|
-
BadRequestException,
|
|
11
|
-
ConflictException,
|
|
12
|
-
NotFoundException,
|
|
13
|
-
} from "@nestjs/common";
|
|
14
|
-
import {
|
|
15
|
-
applyProjectionIndexesForBootstrap,
|
|
16
|
-
createProjectionDialect,
|
|
17
|
-
type ProjectionDialect,
|
|
18
|
-
} from "@nest-yalc-2/crud-gen";
|
|
19
|
-
import { randomUUID } from 'node:crypto';
|
|
20
|
-
import { rm } from 'node:fs/promises';
|
|
21
|
-
import { tmpdir } from 'node:os';
|
|
22
|
-
import { join } from 'node:path';
|
|
23
|
-
import { DataSource } from "typeorm";
|
|
24
|
-
|
|
25
|
-
import { OmniNamedEntity } from '../base/omni-named.entity.js';
|
|
26
|
-
import { OmniRecordEntity } from '../base/omni-record.entity.js';
|
|
27
|
-
import { OmniRelationEntity } from '../base/omni-relation.entity.js';
|
|
28
|
-
import { OmniExternalRefEntity } from '../base/omni-external-ref.entity.js';
|
|
29
|
-
import { OmniExtensionProjectionService } from '../omni-extension-projection.service.js';
|
|
30
|
-
import {
|
|
31
|
-
createOmniExtensionProjectionEntity,
|
|
32
|
-
defineOmniExtensionProjection,
|
|
33
|
-
} from '../omni-extension-projection.definition.js';
|
|
34
|
-
import { OmniExternalRefBindingValidator } from '../omni-external-ref-binding.validator.js';
|
|
35
|
-
import { OmniExternalRefService } from '../omni-external-ref.service.js';
|
|
36
|
-
import { OmniExternalRefInternalType } from '../omni-external-ref-internal-type.enum.js';
|
|
37
|
-
import { defineOmniRelationProjection } from '../omni-relation-projection.definition.js';
|
|
38
|
-
import { OmniRelationProjectionService } from '../omni-relation-projection.service.js';
|
|
39
|
-
import { createOmniRelationKindContract } from '../omni-relation-kind.contract.js';
|
|
40
|
-
import { createOmniProjectionReaderCatalog } from '../omni-projection.catalog.js';
|
|
41
|
-
import { OmniRecordStatus } from '../omni-record-status.enum.js';
|
|
42
|
-
|
|
43
|
-
type ExtensionRow = {
|
|
44
|
-
scopeId: string;
|
|
45
|
-
guid: string;
|
|
46
|
-
label: string;
|
|
47
|
-
payload: Record<string, unknown>;
|
|
48
|
-
revision?: number;
|
|
49
|
-
priority?: number | null;
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const definition = defineOmniExtensionProjection({
|
|
53
|
-
id: "example.extension-projection.v1",
|
|
54
|
-
tableName: "example-extension-projection",
|
|
55
|
-
identity: { column: "guid", uniqueWithinScope: true },
|
|
56
|
-
scope: { column: "scopeId", serverOwned: true },
|
|
57
|
-
revision: { column: "revision" },
|
|
58
|
-
payload: { column: "payload", allowCreate: true },
|
|
59
|
-
deletion: "hard",
|
|
60
|
-
fields: [
|
|
61
|
-
{
|
|
62
|
-
name: "guid",
|
|
63
|
-
storage: "column",
|
|
64
|
-
column: "guid",
|
|
65
|
-
codec: "uuid",
|
|
66
|
-
nullable: false,
|
|
67
|
-
requiredOnCreate: true,
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
name: "label",
|
|
71
|
-
storage: "column",
|
|
72
|
-
column: "label",
|
|
73
|
-
codec: "string",
|
|
74
|
-
nullable: false,
|
|
75
|
-
requiredOnCreate: true,
|
|
76
|
-
query: { filter: ["eq"], sort: true },
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
name: "priority",
|
|
80
|
-
storage: "json",
|
|
81
|
-
path: ["attributes", "priority"],
|
|
82
|
-
codec: "integer",
|
|
83
|
-
nullable: true,
|
|
84
|
-
query: { filter: ["eq", "range"], sort: true },
|
|
85
|
-
index: { name: "example_extension_projection_priority_idx" },
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: "enabled",
|
|
89
|
-
storage: "json",
|
|
90
|
-
path: ["attributes", "enabled"],
|
|
91
|
-
codec: "boolean",
|
|
92
|
-
nullable: false,
|
|
93
|
-
requiredOnCreate: true,
|
|
94
|
-
query: { filter: ["eq"], sort: true },
|
|
95
|
-
index: { name: "example_extension_projection_enabled_idx" },
|
|
96
|
-
},
|
|
97
|
-
],
|
|
98
|
-
owner: {
|
|
99
|
-
kind: "example_extension",
|
|
100
|
-
title: "Example extension",
|
|
101
|
-
status: OmniRecordStatus.Active,
|
|
102
|
-
schema: { id: "example.extension.v1", version: 1 },
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const relationDefinition = defineOmniRelationProjection({
|
|
107
|
-
id: "example.extension-link.v1",
|
|
108
|
-
relation: {
|
|
109
|
-
kind: "links",
|
|
110
|
-
sourceKind: definition.owner.kind,
|
|
111
|
-
targetKind: definition.owner.kind,
|
|
112
|
-
schema: { id: "example.extension-link.v1", version: 1 },
|
|
113
|
-
},
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
const alphaScope = {
|
|
117
|
-
scopeId: "scope-alpha",
|
|
118
|
-
cacheKey: (key: string) => `scope-alpha:${key}`,
|
|
119
|
-
};
|
|
120
|
-
const bravoScope = {
|
|
121
|
-
scopeId: "scope-bravo",
|
|
122
|
-
cacheKey: (key: string) => `scope-bravo:${key}`,
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const firstGuid = "10000000-0000-4000-8000-000000000001";
|
|
126
|
-
const secondGuid = "10000000-0000-4000-8000-000000000002";
|
|
127
|
-
const thirdGuid = "10000000-0000-4000-8000-000000000003";
|
|
128
|
-
const bravoOnlyGuid = "10000000-0000-4000-8000-000000000004";
|
|
129
|
-
|
|
130
|
-
const events = {
|
|
131
|
-
errorBadRequest: (_event: string, options: any) =>
|
|
132
|
-
new BadRequestException(options.response.message),
|
|
133
|
-
errorConflict: (_event: string, options: any) =>
|
|
134
|
-
new ConflictException(options.response.message),
|
|
135
|
-
errorNotFound: (_event: string, options: any) =>
|
|
136
|
-
new NotFoundException(options.response.message),
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const postgresUrl = process.env.OMNI_EXTENSION_PROJECTION_POSTGRES_URL;
|
|
140
|
-
const dialectCases: Array<{
|
|
141
|
-
name: "sqlite" | "postgres";
|
|
142
|
-
url?: string;
|
|
143
|
-
}> = [
|
|
144
|
-
{ name: "sqlite" },
|
|
145
|
-
...(postgresUrl ? [{ name: "postgres" as const, url: postgresUrl }] : []),
|
|
146
|
-
];
|
|
147
|
-
|
|
148
|
-
describe.each(dialectCases)(
|
|
149
|
-
"Omni extension projection (%s)",
|
|
150
|
-
({ name, url }) => {
|
|
151
|
-
let dataSource: DataSource;
|
|
152
|
-
let dialect: ProjectionDialect;
|
|
153
|
-
let extensionEntity: any;
|
|
154
|
-
let service: InstanceType<
|
|
155
|
-
typeof OmniExtensionProjectionService<ExtensionRow>
|
|
156
|
-
>;
|
|
157
|
-
let bravoService: InstanceType<
|
|
158
|
-
typeof OmniExtensionProjectionService<ExtensionRow>
|
|
159
|
-
>;
|
|
160
|
-
|
|
161
|
-
beforeEach(async () => {
|
|
162
|
-
dialect = createProjectionDialect(name);
|
|
163
|
-
extensionEntity = createOmniExtensionProjectionEntity<ExtensionRow>(
|
|
164
|
-
definition,
|
|
165
|
-
dialect,
|
|
166
|
-
);
|
|
167
|
-
dataSource = new DataSource({
|
|
168
|
-
type: name,
|
|
169
|
-
...(name === "sqlite"
|
|
170
|
-
? { database: ":memory:" }
|
|
171
|
-
: { url: url!, ssl: false }),
|
|
172
|
-
dropSchema: true,
|
|
173
|
-
synchronize: true,
|
|
174
|
-
entities: [
|
|
175
|
-
OmniNamedEntity,
|
|
176
|
-
OmniRecordEntity,
|
|
177
|
-
OmniRelationEntity,
|
|
178
|
-
OmniExternalRefEntity,
|
|
179
|
-
extensionEntity,
|
|
180
|
-
],
|
|
181
|
-
});
|
|
182
|
-
await dataSource.initialize();
|
|
183
|
-
await applyProjectionIndexesForBootstrap(dataSource, dialect, definition);
|
|
184
|
-
const extensions = dataSource.getRepository<ExtensionRow>(extensionEntity);
|
|
185
|
-
const owners = dataSource.getRepository(OmniRecordEntity);
|
|
186
|
-
service = new OmniExtensionProjectionService(
|
|
187
|
-
extensions,
|
|
188
|
-
owners,
|
|
189
|
-
dataSource,
|
|
190
|
-
alphaScope,
|
|
191
|
-
dialect,
|
|
192
|
-
events as never,
|
|
193
|
-
definition,
|
|
194
|
-
);
|
|
195
|
-
bravoService = new OmniExtensionProjectionService(
|
|
196
|
-
extensions,
|
|
197
|
-
owners,
|
|
198
|
-
dataSource,
|
|
199
|
-
bravoScope,
|
|
200
|
-
dialect,
|
|
201
|
-
events as never,
|
|
202
|
-
definition,
|
|
203
|
-
);
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
afterEach(async () => {
|
|
207
|
-
if (dataSource.isInitialized) await dataSource.destroy();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it("creates, reads, filters, sorts, patches, and isolates owner identity by scope and kind", async () => {
|
|
211
|
-
const first = await service.createEntity({
|
|
212
|
-
guid: firstGuid,
|
|
213
|
-
label: "first",
|
|
214
|
-
priority: 2,
|
|
215
|
-
enabled: true,
|
|
216
|
-
payload: { retained: true },
|
|
217
|
-
});
|
|
218
|
-
await service.createEntity({
|
|
219
|
-
guid: secondGuid,
|
|
220
|
-
label: "second",
|
|
221
|
-
priority: 1,
|
|
222
|
-
enabled: false,
|
|
223
|
-
});
|
|
224
|
-
await bravoService.createEntity({
|
|
225
|
-
guid: firstGuid,
|
|
226
|
-
label: "bravo copy",
|
|
227
|
-
priority: 8,
|
|
228
|
-
enabled: true,
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
expect(first).toMatchObject({
|
|
232
|
-
guid: firstGuid,
|
|
233
|
-
label: "first",
|
|
234
|
-
priority: 2,
|
|
235
|
-
revision: 1,
|
|
236
|
-
payload: { retained: true, attributes: { priority: 2, enabled: true } },
|
|
237
|
-
});
|
|
238
|
-
expect(await bravoService.getEntity({ guid: firstGuid })).toMatchObject({
|
|
239
|
-
label: "bravo copy",
|
|
240
|
-
priority: 8,
|
|
241
|
-
enabled: true,
|
|
242
|
-
revision: 1,
|
|
243
|
-
});
|
|
244
|
-
const [grid, count] = (await service.getEntityListExtended(
|
|
245
|
-
{
|
|
246
|
-
where: { filters: { priority: 2, enabled: true } },
|
|
247
|
-
order: { label: "DESC" },
|
|
248
|
-
} as never,
|
|
249
|
-
true,
|
|
250
|
-
)) as [ExtensionRow[], number];
|
|
251
|
-
expect(count).toBe(1);
|
|
252
|
-
expect(grid).toEqual([expect.objectContaining({ guid: firstGuid })]);
|
|
253
|
-
|
|
254
|
-
const updated = await service.updateEntity(
|
|
255
|
-
{ guid: firstGuid },
|
|
256
|
-
{
|
|
257
|
-
expectedRevision: 1,
|
|
258
|
-
priority: 3,
|
|
259
|
-
enabled: false,
|
|
260
|
-
label: "first updated",
|
|
261
|
-
},
|
|
262
|
-
);
|
|
263
|
-
expect(updated).toMatchObject({
|
|
264
|
-
label: "first updated",
|
|
265
|
-
priority: 3,
|
|
266
|
-
revision: 2,
|
|
267
|
-
payload: {
|
|
268
|
-
retained: true,
|
|
269
|
-
attributes: { priority: 3, enabled: false },
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
await expect(
|
|
273
|
-
service.updateEntity(
|
|
274
|
-
{ guid: firstGuid },
|
|
275
|
-
{ expectedRevision: 1, label: "stale" },
|
|
276
|
-
),
|
|
277
|
-
).rejects.toThrow("stale");
|
|
278
|
-
|
|
279
|
-
const owners = dataSource.getRepository(OmniRecordEntity);
|
|
280
|
-
await owners.insert({
|
|
281
|
-
scopeId: alphaScope.scopeId,
|
|
282
|
-
guid: thirdGuid,
|
|
283
|
-
title: "Other owner",
|
|
284
|
-
kind: "other_extension",
|
|
285
|
-
status: OmniRecordStatus.Active,
|
|
286
|
-
payloadSchemaId: "other.extension.v1",
|
|
287
|
-
payloadSchemaVersion: 1,
|
|
288
|
-
});
|
|
289
|
-
expect(await service.getEntity({ guid: thirdGuid })).toBeNull();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it("rolls back owner creation when the extension insert fails and cascades extension cleanup on delete", async () => {
|
|
293
|
-
await service.createEntity({
|
|
294
|
-
guid: firstGuid,
|
|
295
|
-
label: "unique label",
|
|
296
|
-
priority: 1,
|
|
297
|
-
enabled: true,
|
|
298
|
-
});
|
|
299
|
-
const extensions = dataSource.getRepository<ExtensionRow>(extensionEntity);
|
|
300
|
-
await dataSource.query(
|
|
301
|
-
`CREATE UNIQUE INDEX "example_extension_projection_label_unique" ON "${definition.tableName}" ("label")`,
|
|
302
|
-
);
|
|
303
|
-
await expect(
|
|
304
|
-
service.createEntity({
|
|
305
|
-
guid: secondGuid,
|
|
306
|
-
label: "unique label",
|
|
307
|
-
priority: 2,
|
|
308
|
-
enabled: false,
|
|
309
|
-
}),
|
|
310
|
-
).rejects.toThrow("already exists");
|
|
311
|
-
expect(
|
|
312
|
-
await dataSource.getRepository(OmniRecordEntity).findOneBy({
|
|
313
|
-
scopeId: alphaScope.scopeId,
|
|
314
|
-
guid: secondGuid,
|
|
315
|
-
}),
|
|
316
|
-
).toBeNull();
|
|
317
|
-
|
|
318
|
-
await service.deleteEntity({ guid: firstGuid });
|
|
319
|
-
expect(
|
|
320
|
-
await dataSource.getRepository(OmniRecordEntity).countBy({
|
|
321
|
-
scopeId: alphaScope.scopeId,
|
|
322
|
-
guid: firstGuid,
|
|
323
|
-
}),
|
|
324
|
-
).toBe(0);
|
|
325
|
-
expect(
|
|
326
|
-
await extensions.countBy({
|
|
327
|
-
scopeId: alphaScope.scopeId,
|
|
328
|
-
guid: firstGuid,
|
|
329
|
-
}),
|
|
330
|
-
).toBe(0);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it("rolls back the owner revision when the extension row disappears during a patch", async () => {
|
|
334
|
-
await service.createEntity({
|
|
335
|
-
guid: firstGuid,
|
|
336
|
-
label: "rollback candidate",
|
|
337
|
-
priority: 1,
|
|
338
|
-
enabled: true,
|
|
339
|
-
});
|
|
340
|
-
const patchValues = jest
|
|
341
|
-
.spyOn(dialect, "patchValues")
|
|
342
|
-
.mockResolvedValue(0);
|
|
343
|
-
|
|
344
|
-
await expect(
|
|
345
|
-
service.updateEntity(
|
|
346
|
-
{ guid: firstGuid },
|
|
347
|
-
{ expectedRevision: 1, label: "must roll back" },
|
|
348
|
-
),
|
|
349
|
-
).rejects.toThrow("extension row is unavailable");
|
|
350
|
-
expect(
|
|
351
|
-
await dataSource.getRepository(OmniRecordEntity).findOneByOrFail({
|
|
352
|
-
scopeId: alphaScope.scopeId,
|
|
353
|
-
guid: firstGuid,
|
|
354
|
-
}),
|
|
355
|
-
).toMatchObject({ revision: 1 });
|
|
356
|
-
patchValues.mockRestore();
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it("uses one generic relation table with fixed registered endpoint semantics and validates reusable external-ref targets", async () => {
|
|
360
|
-
await service.createEntity({
|
|
361
|
-
guid: firstGuid,
|
|
362
|
-
label: "source",
|
|
363
|
-
priority: 1,
|
|
364
|
-
enabled: true,
|
|
365
|
-
});
|
|
366
|
-
await service.createEntity({
|
|
367
|
-
guid: secondGuid,
|
|
368
|
-
label: "target",
|
|
369
|
-
priority: 2,
|
|
370
|
-
enabled: false,
|
|
371
|
-
});
|
|
372
|
-
await bravoService.createEntity({
|
|
373
|
-
guid: bravoOnlyGuid,
|
|
374
|
-
label: "bravo only",
|
|
375
|
-
priority: 3,
|
|
376
|
-
enabled: true,
|
|
377
|
-
});
|
|
378
|
-
const records = dataSource.getRepository(OmniRecordEntity);
|
|
379
|
-
const relations = new OmniRelationProjectionService(
|
|
380
|
-
dataSource.getRepository(OmniRelationEntity) as never,
|
|
381
|
-
alphaScope,
|
|
382
|
-
"hard",
|
|
383
|
-
records,
|
|
384
|
-
createOmniRelationKindContract(["links"]),
|
|
385
|
-
relationDefinition,
|
|
386
|
-
);
|
|
387
|
-
const relationGuid = "10000000-0000-4000-8000-000000000010";
|
|
388
|
-
await relations.createEntity({
|
|
389
|
-
guid: relationGuid,
|
|
390
|
-
sourceRecordId: firstGuid,
|
|
391
|
-
targetRecordId: secondGuid,
|
|
392
|
-
payload: { strength: 1 },
|
|
393
|
-
});
|
|
394
|
-
await expect(
|
|
395
|
-
relations.createEntity({
|
|
396
|
-
guid: "10000000-0000-4000-8000-000000000011",
|
|
397
|
-
sourceRecordId: firstGuid,
|
|
398
|
-
targetRecordId: bravoOnlyGuid,
|
|
399
|
-
}),
|
|
400
|
-
).rejects.toThrow("not found");
|
|
401
|
-
await expect(
|
|
402
|
-
relations.createEntity({
|
|
403
|
-
guid: "10000000-0000-4000-8000-000000000012",
|
|
404
|
-
sourceRecordId: firstGuid,
|
|
405
|
-
targetRecordId: secondGuid,
|
|
406
|
-
}),
|
|
407
|
-
).rejects.toThrow();
|
|
408
|
-
await expect(
|
|
409
|
-
relations.createEntity({
|
|
410
|
-
guid: "10000000-0000-4000-8000-000000000013",
|
|
411
|
-
sourceRecordId: firstGuid,
|
|
412
|
-
targetRecordId: secondGuid,
|
|
413
|
-
kind: "links",
|
|
414
|
-
}),
|
|
415
|
-
).rejects.toThrow("server-owned");
|
|
416
|
-
const otherRelationGuid = "10000000-0000-4000-8000-000000000014";
|
|
417
|
-
await dataSource.getRepository(OmniRelationEntity).insert({
|
|
418
|
-
scopeId: alphaScope.scopeId,
|
|
419
|
-
guid: otherRelationGuid,
|
|
420
|
-
sourceRecordId: firstGuid,
|
|
421
|
-
targetRecordId: secondGuid,
|
|
422
|
-
kind: "other_link",
|
|
423
|
-
status: "active",
|
|
424
|
-
payloadSchemaId: "other.link.v1",
|
|
425
|
-
payloadSchemaVersion: 1,
|
|
426
|
-
});
|
|
427
|
-
const otherSchemaRelationGuid =
|
|
428
|
-
"10000000-0000-4000-8000-000000000015";
|
|
429
|
-
await dataSource.getRepository(OmniRelationEntity).insert({
|
|
430
|
-
scopeId: alphaScope.scopeId,
|
|
431
|
-
guid: otherSchemaRelationGuid,
|
|
432
|
-
sourceRecordId: secondGuid,
|
|
433
|
-
targetRecordId: firstGuid,
|
|
434
|
-
kind: "links",
|
|
435
|
-
status: "active",
|
|
436
|
-
payloadSchemaId: "other.link.v1",
|
|
437
|
-
payloadSchemaVersion: 1,
|
|
438
|
-
});
|
|
439
|
-
expect(
|
|
440
|
-
await relations.getEntity({ guid: otherRelationGuid }),
|
|
441
|
-
).toBeNull();
|
|
442
|
-
expect(await relations.getEntity(otherRelationGuid as never)).toBeNull();
|
|
443
|
-
expect(
|
|
444
|
-
await relations.getEntity({ guid: otherSchemaRelationGuid }),
|
|
445
|
-
).toBeNull();
|
|
446
|
-
await expect(
|
|
447
|
-
relations.getEntity({ guid: relationGuid, kind: "links" } as never),
|
|
448
|
-
).rejects.toThrow("server-owned");
|
|
449
|
-
await expect(
|
|
450
|
-
relations.updateEntity(
|
|
451
|
-
{ guid: otherRelationGuid },
|
|
452
|
-
{ payload: { no: true } },
|
|
453
|
-
),
|
|
454
|
-
).rejects.toThrow();
|
|
455
|
-
await expect(
|
|
456
|
-
relations.deleteEntity({ guid: otherRelationGuid }),
|
|
457
|
-
).rejects.toThrow();
|
|
458
|
-
await expect(
|
|
459
|
-
relations.getEntityListExtended({
|
|
460
|
-
where: { filters: { kind: "other_link" } },
|
|
461
|
-
} as never),
|
|
462
|
-
).rejects.toThrow("server-owned");
|
|
463
|
-
await expect(
|
|
464
|
-
relations.updateEntity(
|
|
465
|
-
{ guid: relationGuid, kind: "links" } as never,
|
|
466
|
-
{ payload: { no: true }, expectedRevision: 1 },
|
|
467
|
-
),
|
|
468
|
-
).rejects.toThrow("server-owned");
|
|
469
|
-
await expect(
|
|
470
|
-
relations.deleteEntity({ guid: relationGuid, kind: "links" } as never),
|
|
471
|
-
).rejects.toThrow("server-owned");
|
|
472
|
-
expect(await relations.getEntityListExtended({})).toEqual([
|
|
473
|
-
expect.objectContaining({ guid: relationGuid }),
|
|
474
|
-
]);
|
|
475
|
-
expect(
|
|
476
|
-
dataSource.entityMetadatas.filter(
|
|
477
|
-
(metadata) => metadata.tableName === "omni-relation",
|
|
478
|
-
),
|
|
479
|
-
).toHaveLength(1);
|
|
480
|
-
|
|
481
|
-
const validator = new OmniExternalRefBindingValidator(
|
|
482
|
-
records,
|
|
483
|
-
alphaScope,
|
|
484
|
-
);
|
|
485
|
-
await expect(
|
|
486
|
-
validator.assertTarget({
|
|
487
|
-
internalId: firstGuid,
|
|
488
|
-
internalType: OmniExternalRefInternalType.Record,
|
|
489
|
-
}),
|
|
490
|
-
).resolves.toMatchObject({ guid: firstGuid });
|
|
491
|
-
await expect(
|
|
492
|
-
new OmniExternalRefBindingValidator(records, bravoScope).assertTarget({
|
|
493
|
-
internalId: firstGuid,
|
|
494
|
-
internalType: OmniExternalRefInternalType.Record,
|
|
495
|
-
}),
|
|
496
|
-
).rejects.toThrow("not found");
|
|
497
|
-
|
|
498
|
-
const externalRefs = new OmniExternalRefService(
|
|
499
|
-
dataSource.getRepository(OmniExternalRefEntity) as never,
|
|
500
|
-
alphaScope,
|
|
501
|
-
"hard",
|
|
502
|
-
validator,
|
|
503
|
-
);
|
|
504
|
-
const externalRefGuid = "10000000-0000-4000-8000-000000000020";
|
|
505
|
-
await externalRefs.createEntity({
|
|
506
|
-
guid: externalRefGuid,
|
|
507
|
-
provider: "example",
|
|
508
|
-
externalId: "one",
|
|
509
|
-
internalType: OmniExternalRefInternalType.Record,
|
|
510
|
-
internalId: firstGuid,
|
|
511
|
-
});
|
|
512
|
-
await expect(
|
|
513
|
-
externalRefs.createEntity({
|
|
514
|
-
guid: "10000000-0000-4000-8000-000000000021",
|
|
515
|
-
provider: "example",
|
|
516
|
-
externalId: "missing",
|
|
517
|
-
internalType: OmniExternalRefInternalType.Record,
|
|
518
|
-
internalId: thirdGuid,
|
|
519
|
-
}),
|
|
520
|
-
).rejects.toThrow("not found");
|
|
521
|
-
await expect(
|
|
522
|
-
new OmniExternalRefService(
|
|
523
|
-
dataSource.getRepository(OmniExternalRefEntity) as never,
|
|
524
|
-
bravoScope,
|
|
525
|
-
"hard",
|
|
526
|
-
new OmniExternalRefBindingValidator(records, bravoScope),
|
|
527
|
-
).createEntity({
|
|
528
|
-
guid: "10000000-0000-4000-8000-000000000022",
|
|
529
|
-
provider: "example",
|
|
530
|
-
externalId: "cross-scope",
|
|
531
|
-
internalType: OmniExternalRefInternalType.Record,
|
|
532
|
-
internalId: firstGuid,
|
|
533
|
-
}),
|
|
534
|
-
).rejects.toThrow("not found");
|
|
535
|
-
await expect(
|
|
536
|
-
externalRefs.updateEntity(
|
|
537
|
-
{ guid: externalRefGuid },
|
|
538
|
-
{ internalId: bravoOnlyGuid },
|
|
539
|
-
),
|
|
540
|
-
).rejects.toThrow("immutable");
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
it('supports one multi-kind relation surface, public aliases, revisions, and bounded catalog readers', async () => {
|
|
544
|
-
await service.createEntity({
|
|
545
|
-
guid: firstGuid,
|
|
546
|
-
label: 'source',
|
|
547
|
-
priority: 1,
|
|
548
|
-
enabled: true,
|
|
549
|
-
});
|
|
550
|
-
await service.createEntity({
|
|
551
|
-
guid: secondGuid,
|
|
552
|
-
label: 'target',
|
|
553
|
-
priority: 2,
|
|
554
|
-
enabled: false,
|
|
555
|
-
});
|
|
556
|
-
const multiDefinition = defineOmniRelationProjection({
|
|
557
|
-
id: 'example.multi-link.v1',
|
|
558
|
-
relation: {
|
|
559
|
-
allowedKinds: ['links', 'blocks'],
|
|
560
|
-
sourceKind: definition.owner.kind,
|
|
561
|
-
targetKind: definition.owner.kind,
|
|
562
|
-
schema: { id: 'example.multi-link.v1', version: 1 },
|
|
563
|
-
},
|
|
564
|
-
aliases: {
|
|
565
|
-
kind: 'linkKind',
|
|
566
|
-
source: 'sourceWorkItemId',
|
|
567
|
-
target: 'targetWorkItemId',
|
|
568
|
-
payload: 'metadata',
|
|
569
|
-
},
|
|
570
|
-
});
|
|
571
|
-
const relations = new OmniRelationProjectionService(
|
|
572
|
-
dataSource.getRepository(OmniRelationEntity),
|
|
573
|
-
alphaScope,
|
|
574
|
-
'hard',
|
|
575
|
-
dataSource.getRepository(OmniRecordEntity),
|
|
576
|
-
createOmniRelationKindContract(['links', 'blocks']),
|
|
577
|
-
multiDefinition,
|
|
578
|
-
dataSource,
|
|
579
|
-
);
|
|
580
|
-
const relationGuid = '10000000-0000-4000-8000-000000000030';
|
|
581
|
-
const relation = await relations.createEntity({
|
|
582
|
-
guid: relationGuid,
|
|
583
|
-
linkKind: 'links',
|
|
584
|
-
sourceWorkItemId: firstGuid,
|
|
585
|
-
targetWorkItemId: secondGuid,
|
|
586
|
-
metadata: { enabled: true },
|
|
587
|
-
} as never);
|
|
588
|
-
expect(relation).toMatchObject({
|
|
589
|
-
linkKind: 'links',
|
|
590
|
-
sourceWorkItemId: firstGuid,
|
|
591
|
-
targetWorkItemId: secondGuid,
|
|
592
|
-
metadata: { enabled: true },
|
|
593
|
-
revision: 1,
|
|
594
|
-
});
|
|
595
|
-
await expect(
|
|
596
|
-
relations.createEntity({
|
|
597
|
-
guid: '10000000-0000-4000-8000-000000000031',
|
|
598
|
-
linkKind: 'not_registered',
|
|
599
|
-
sourceWorkItemId: firstGuid,
|
|
600
|
-
targetWorkItemId: secondGuid,
|
|
601
|
-
} as never),
|
|
602
|
-
).rejects.toThrow('not allowed');
|
|
603
|
-
await expect(
|
|
604
|
-
relations.updateEntity(
|
|
605
|
-
{ guid: relationGuid },
|
|
606
|
-
{ sourceWorkItemId: secondGuid, expectedRevision: 1 } as never,
|
|
607
|
-
),
|
|
608
|
-
).rejects.toThrow('immutable');
|
|
609
|
-
const updated = await relations.updateEntity(
|
|
610
|
-
{ guid: relationGuid },
|
|
611
|
-
{ metadata: { enabled: false }, expectedRevision: 1 } as never,
|
|
612
|
-
);
|
|
613
|
-
expect(updated).toMatchObject({
|
|
614
|
-
metadata: { enabled: false },
|
|
615
|
-
revision: 2,
|
|
616
|
-
});
|
|
617
|
-
await expect(
|
|
618
|
-
relations.updateEntity(
|
|
619
|
-
{ guid: relationGuid },
|
|
620
|
-
{ metadata: { enabled: true }, expectedRevision: 1 } as never,
|
|
621
|
-
),
|
|
622
|
-
).rejects.toThrow('revision conflict');
|
|
623
|
-
|
|
624
|
-
const catalog = createOmniProjectionReaderCatalog([
|
|
625
|
-
{
|
|
626
|
-
type: 'extension',
|
|
627
|
-
id: definition.id,
|
|
628
|
-
entity: extensionEntity,
|
|
629
|
-
definition,
|
|
630
|
-
},
|
|
631
|
-
{
|
|
632
|
-
type: 'relation',
|
|
633
|
-
id: multiDefinition.id,
|
|
634
|
-
definition: multiDefinition,
|
|
635
|
-
},
|
|
636
|
-
]);
|
|
637
|
-
const readers = catalog.bind(dataSource.manager, alphaScope);
|
|
638
|
-
const projectedExtensions = await readers.extension(definition.id).list({
|
|
639
|
-
where: { label: 'source' },
|
|
640
|
-
take: 1,
|
|
641
|
-
});
|
|
642
|
-
expect(projectedExtensions).toEqual([
|
|
643
|
-
expect.objectContaining({
|
|
644
|
-
scopeId: alphaScope.scopeId,
|
|
645
|
-
guid: firstGuid,
|
|
646
|
-
label: 'source',
|
|
647
|
-
revision: 1,
|
|
648
|
-
createdAt: expect.any(Date),
|
|
649
|
-
updatedAt: expect.any(Date),
|
|
650
|
-
}),
|
|
651
|
-
]);
|
|
652
|
-
expect(projectedExtensions[0]).not.toHaveProperty('kind');
|
|
653
|
-
await expect(
|
|
654
|
-
readers.extension(definition.id).list({ where: { payload: {} } }),
|
|
655
|
-
).rejects.toThrow('declared eq fields');
|
|
656
|
-
await expect(
|
|
657
|
-
readers.relation(multiDefinition.id).list({
|
|
658
|
-
sourceRecordId: firstGuid,
|
|
659
|
-
take: 1,
|
|
660
|
-
}),
|
|
661
|
-
).resolves.toEqual([expect.objectContaining({ guid: relationGuid })]);
|
|
662
|
-
await expect(
|
|
663
|
-
readers.relation(multiDefinition.id).list({ take: 0 }),
|
|
664
|
-
).rejects.toThrow('take must be an integer');
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
it('maps concurrent revision and lifecycle-predicate writes to one success and one conflict', async () => {
|
|
668
|
-
const sqlitePath = join(tmpdir(), `omni-projection-race-${randomUUID()}.sqlite`);
|
|
669
|
-
const options = {
|
|
670
|
-
type: name,
|
|
671
|
-
...(name === 'sqlite'
|
|
672
|
-
? { database: sqlitePath }
|
|
673
|
-
: { url: url!, ssl: false }),
|
|
674
|
-
entities: [
|
|
675
|
-
OmniNamedEntity,
|
|
676
|
-
OmniRecordEntity,
|
|
677
|
-
OmniRelationEntity,
|
|
678
|
-
OmniExternalRefEntity,
|
|
679
|
-
extensionEntity,
|
|
680
|
-
],
|
|
681
|
-
} as const;
|
|
682
|
-
const primary = new DataSource({
|
|
683
|
-
...options,
|
|
684
|
-
dropSchema: true,
|
|
685
|
-
synchronize: true,
|
|
686
|
-
} as never);
|
|
687
|
-
const secondary = new DataSource({
|
|
688
|
-
...options,
|
|
689
|
-
synchronize: false,
|
|
690
|
-
} as never);
|
|
691
|
-
await primary.initialize();
|
|
692
|
-
await secondary.initialize();
|
|
693
|
-
try {
|
|
694
|
-
const first = new OmniExtensionProjectionService(
|
|
695
|
-
primary.getRepository<ExtensionRow>(extensionEntity),
|
|
696
|
-
primary.getRepository(OmniRecordEntity),
|
|
697
|
-
primary,
|
|
698
|
-
alphaScope,
|
|
699
|
-
createProjectionDialect(name),
|
|
700
|
-
events as never,
|
|
701
|
-
definition,
|
|
702
|
-
);
|
|
703
|
-
const second = new OmniExtensionProjectionService(
|
|
704
|
-
secondary.getRepository<ExtensionRow>(extensionEntity),
|
|
705
|
-
secondary.getRepository(OmniRecordEntity),
|
|
706
|
-
secondary,
|
|
707
|
-
alphaScope,
|
|
708
|
-
createProjectionDialect(name),
|
|
709
|
-
events as never,
|
|
710
|
-
definition,
|
|
711
|
-
);
|
|
712
|
-
await first.createEntity({
|
|
713
|
-
guid: firstGuid,
|
|
714
|
-
label: 'revision-race',
|
|
715
|
-
enabled: true,
|
|
716
|
-
});
|
|
717
|
-
const revisionOutcomes = await Promise.allSettled([
|
|
718
|
-
first.updateEntity(
|
|
719
|
-
{ guid: firstGuid },
|
|
720
|
-
{ expectedRevision: 1, label: 'revision race one' },
|
|
721
|
-
),
|
|
722
|
-
second.updateEntity(
|
|
723
|
-
{ guid: firstGuid },
|
|
724
|
-
{ expectedRevision: 1, label: 'revision race two' },
|
|
725
|
-
),
|
|
726
|
-
]);
|
|
727
|
-
expect(
|
|
728
|
-
revisionOutcomes.filter((result) => result.status === 'fulfilled'),
|
|
729
|
-
).toHaveLength(1);
|
|
730
|
-
expect(
|
|
731
|
-
revisionOutcomes.filter((result) => result.status === 'rejected'),
|
|
732
|
-
).toHaveLength(1);
|
|
733
|
-
|
|
734
|
-
let waiting = 0;
|
|
735
|
-
let release: (() => void) | undefined;
|
|
736
|
-
const gate = new Promise<void>((resolve) => {
|
|
737
|
-
release = resolve;
|
|
738
|
-
});
|
|
739
|
-
const lifecycle = {
|
|
740
|
-
beforeCreate: async (context: any) => {
|
|
741
|
-
const existing = await context.readers.extension(definition.id).list({
|
|
742
|
-
where: { label: 'predicate-singleton' },
|
|
743
|
-
take: 1,
|
|
744
|
-
});
|
|
745
|
-
if (existing.length > 0) {
|
|
746
|
-
throw new ConflictException('Lifecycle predicate already holds.');
|
|
747
|
-
}
|
|
748
|
-
waiting += 1;
|
|
749
|
-
if (waiting === 2) release?.();
|
|
750
|
-
await gate;
|
|
751
|
-
},
|
|
752
|
-
};
|
|
753
|
-
const guardedFirst = new OmniExtensionProjectionService(
|
|
754
|
-
primary.getRepository<ExtensionRow>(extensionEntity),
|
|
755
|
-
primary.getRepository(OmniRecordEntity),
|
|
756
|
-
primary,
|
|
757
|
-
alphaScope,
|
|
758
|
-
createProjectionDialect(name),
|
|
759
|
-
events as never,
|
|
760
|
-
definition,
|
|
761
|
-
lifecycle,
|
|
762
|
-
);
|
|
763
|
-
const guardedSecond = new OmniExtensionProjectionService(
|
|
764
|
-
secondary.getRepository<ExtensionRow>(extensionEntity),
|
|
765
|
-
secondary.getRepository(OmniRecordEntity),
|
|
766
|
-
secondary,
|
|
767
|
-
alphaScope,
|
|
768
|
-
createProjectionDialect(name),
|
|
769
|
-
events as never,
|
|
770
|
-
definition,
|
|
771
|
-
lifecycle,
|
|
772
|
-
);
|
|
773
|
-
const lifecycleOutcomes = await Promise.allSettled([
|
|
774
|
-
guardedFirst.createEntity({
|
|
775
|
-
guid: secondGuid,
|
|
776
|
-
label: 'predicate-singleton',
|
|
777
|
-
enabled: true,
|
|
778
|
-
}),
|
|
779
|
-
guardedSecond.createEntity({
|
|
780
|
-
guid: thirdGuid,
|
|
781
|
-
label: 'predicate-singleton',
|
|
782
|
-
enabled: false,
|
|
783
|
-
}),
|
|
784
|
-
]);
|
|
785
|
-
expect(
|
|
786
|
-
lifecycleOutcomes.filter((result) => result.status === 'fulfilled'),
|
|
787
|
-
).toHaveLength(1);
|
|
788
|
-
expect(
|
|
789
|
-
lifecycleOutcomes.filter((result) => result.status === 'rejected'),
|
|
790
|
-
).toHaveLength(1);
|
|
791
|
-
expect(
|
|
792
|
-
lifecycleOutcomes.find(
|
|
793
|
-
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
|
794
|
-
)!.reason,
|
|
795
|
-
).toBeInstanceOf(ConflictException);
|
|
796
|
-
} finally {
|
|
797
|
-
if (secondary.isInitialized) await secondary.destroy();
|
|
798
|
-
if (primary.isInitialized) {
|
|
799
|
-
await primary.dropDatabase();
|
|
800
|
-
await primary.destroy();
|
|
801
|
-
}
|
|
802
|
-
if (name === 'sqlite') await rm(sqlitePath, { force: true });
|
|
803
|
-
}
|
|
804
|
-
});
|
|
805
|
-
},
|
|
806
|
-
);
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
jest,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
import {
|
|
10
|
+
BadRequestException,
|
|
11
|
+
ConflictException,
|
|
12
|
+
NotFoundException,
|
|
13
|
+
} from "@nestjs/common";
|
|
14
|
+
import {
|
|
15
|
+
applyProjectionIndexesForBootstrap,
|
|
16
|
+
createProjectionDialect,
|
|
17
|
+
type ProjectionDialect,
|
|
18
|
+
} from "@nest-yalc-2/crud-gen";
|
|
19
|
+
import { randomUUID } from 'node:crypto';
|
|
20
|
+
import { rm } from 'node:fs/promises';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { DataSource } from "typeorm";
|
|
24
|
+
|
|
25
|
+
import { OmniNamedEntity } from '../base/omni-named.entity.js';
|
|
26
|
+
import { OmniRecordEntity } from '../base/omni-record.entity.js';
|
|
27
|
+
import { OmniRelationEntity } from '../base/omni-relation.entity.js';
|
|
28
|
+
import { OmniExternalRefEntity } from '../base/omni-external-ref.entity.js';
|
|
29
|
+
import { OmniExtensionProjectionService } from '../omni-extension-projection.service.js';
|
|
30
|
+
import {
|
|
31
|
+
createOmniExtensionProjectionEntity,
|
|
32
|
+
defineOmniExtensionProjection,
|
|
33
|
+
} from '../omni-extension-projection.definition.js';
|
|
34
|
+
import { OmniExternalRefBindingValidator } from '../omni-external-ref-binding.validator.js';
|
|
35
|
+
import { OmniExternalRefService } from '../omni-external-ref.service.js';
|
|
36
|
+
import { OmniExternalRefInternalType } from '../omni-external-ref-internal-type.enum.js';
|
|
37
|
+
import { defineOmniRelationProjection } from '../omni-relation-projection.definition.js';
|
|
38
|
+
import { OmniRelationProjectionService } from '../omni-relation-projection.service.js';
|
|
39
|
+
import { createOmniRelationKindContract } from '../omni-relation-kind.contract.js';
|
|
40
|
+
import { createOmniProjectionReaderCatalog } from '../omni-projection.catalog.js';
|
|
41
|
+
import { OmniRecordStatus } from '../omni-record-status.enum.js';
|
|
42
|
+
|
|
43
|
+
type ExtensionRow = {
|
|
44
|
+
scopeId: string;
|
|
45
|
+
guid: string;
|
|
46
|
+
label: string;
|
|
47
|
+
payload: Record<string, unknown>;
|
|
48
|
+
revision?: number;
|
|
49
|
+
priority?: number | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const definition = defineOmniExtensionProjection({
|
|
53
|
+
id: "example.extension-projection.v1",
|
|
54
|
+
tableName: "example-extension-projection",
|
|
55
|
+
identity: { column: "guid", uniqueWithinScope: true },
|
|
56
|
+
scope: { column: "scopeId", serverOwned: true },
|
|
57
|
+
revision: { column: "revision" },
|
|
58
|
+
payload: { column: "payload", allowCreate: true },
|
|
59
|
+
deletion: "hard",
|
|
60
|
+
fields: [
|
|
61
|
+
{
|
|
62
|
+
name: "guid",
|
|
63
|
+
storage: "column",
|
|
64
|
+
column: "guid",
|
|
65
|
+
codec: "uuid",
|
|
66
|
+
nullable: false,
|
|
67
|
+
requiredOnCreate: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "label",
|
|
71
|
+
storage: "column",
|
|
72
|
+
column: "label",
|
|
73
|
+
codec: "string",
|
|
74
|
+
nullable: false,
|
|
75
|
+
requiredOnCreate: true,
|
|
76
|
+
query: { filter: ["eq"], sort: true },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: "priority",
|
|
80
|
+
storage: "json",
|
|
81
|
+
path: ["attributes", "priority"],
|
|
82
|
+
codec: "integer",
|
|
83
|
+
nullable: true,
|
|
84
|
+
query: { filter: ["eq", "range"], sort: true },
|
|
85
|
+
index: { name: "example_extension_projection_priority_idx" },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "enabled",
|
|
89
|
+
storage: "json",
|
|
90
|
+
path: ["attributes", "enabled"],
|
|
91
|
+
codec: "boolean",
|
|
92
|
+
nullable: false,
|
|
93
|
+
requiredOnCreate: true,
|
|
94
|
+
query: { filter: ["eq"], sort: true },
|
|
95
|
+
index: { name: "example_extension_projection_enabled_idx" },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
owner: {
|
|
99
|
+
kind: "example_extension",
|
|
100
|
+
title: "Example extension",
|
|
101
|
+
status: OmniRecordStatus.Active,
|
|
102
|
+
schema: { id: "example.extension.v1", version: 1 },
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const relationDefinition = defineOmniRelationProjection({
|
|
107
|
+
id: "example.extension-link.v1",
|
|
108
|
+
relation: {
|
|
109
|
+
kind: "links",
|
|
110
|
+
sourceKind: definition.owner.kind,
|
|
111
|
+
targetKind: definition.owner.kind,
|
|
112
|
+
schema: { id: "example.extension-link.v1", version: 1 },
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const alphaScope = {
|
|
117
|
+
scopeId: "scope-alpha",
|
|
118
|
+
cacheKey: (key: string) => `scope-alpha:${key}`,
|
|
119
|
+
};
|
|
120
|
+
const bravoScope = {
|
|
121
|
+
scopeId: "scope-bravo",
|
|
122
|
+
cacheKey: (key: string) => `scope-bravo:${key}`,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const firstGuid = "10000000-0000-4000-8000-000000000001";
|
|
126
|
+
const secondGuid = "10000000-0000-4000-8000-000000000002";
|
|
127
|
+
const thirdGuid = "10000000-0000-4000-8000-000000000003";
|
|
128
|
+
const bravoOnlyGuid = "10000000-0000-4000-8000-000000000004";
|
|
129
|
+
|
|
130
|
+
const events = {
|
|
131
|
+
errorBadRequest: (_event: string, options: any) =>
|
|
132
|
+
new BadRequestException(options.response.message),
|
|
133
|
+
errorConflict: (_event: string, options: any) =>
|
|
134
|
+
new ConflictException(options.response.message),
|
|
135
|
+
errorNotFound: (_event: string, options: any) =>
|
|
136
|
+
new NotFoundException(options.response.message),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const postgresUrl = process.env.OMNI_EXTENSION_PROJECTION_POSTGRES_URL;
|
|
140
|
+
const dialectCases: Array<{
|
|
141
|
+
name: "sqlite" | "postgres";
|
|
142
|
+
url?: string;
|
|
143
|
+
}> = [
|
|
144
|
+
{ name: "sqlite" },
|
|
145
|
+
...(postgresUrl ? [{ name: "postgres" as const, url: postgresUrl }] : []),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
describe.each(dialectCases)(
|
|
149
|
+
"Omni extension projection (%s)",
|
|
150
|
+
({ name, url }) => {
|
|
151
|
+
let dataSource: DataSource;
|
|
152
|
+
let dialect: ProjectionDialect;
|
|
153
|
+
let extensionEntity: any;
|
|
154
|
+
let service: InstanceType<
|
|
155
|
+
typeof OmniExtensionProjectionService<ExtensionRow>
|
|
156
|
+
>;
|
|
157
|
+
let bravoService: InstanceType<
|
|
158
|
+
typeof OmniExtensionProjectionService<ExtensionRow>
|
|
159
|
+
>;
|
|
160
|
+
|
|
161
|
+
beforeEach(async () => {
|
|
162
|
+
dialect = createProjectionDialect(name);
|
|
163
|
+
extensionEntity = createOmniExtensionProjectionEntity<ExtensionRow>(
|
|
164
|
+
definition,
|
|
165
|
+
dialect,
|
|
166
|
+
);
|
|
167
|
+
dataSource = new DataSource({
|
|
168
|
+
type: name,
|
|
169
|
+
...(name === "sqlite"
|
|
170
|
+
? { database: ":memory:" }
|
|
171
|
+
: { url: url!, ssl: false }),
|
|
172
|
+
dropSchema: true,
|
|
173
|
+
synchronize: true,
|
|
174
|
+
entities: [
|
|
175
|
+
OmniNamedEntity,
|
|
176
|
+
OmniRecordEntity,
|
|
177
|
+
OmniRelationEntity,
|
|
178
|
+
OmniExternalRefEntity,
|
|
179
|
+
extensionEntity,
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
await dataSource.initialize();
|
|
183
|
+
await applyProjectionIndexesForBootstrap(dataSource, dialect, definition);
|
|
184
|
+
const extensions = dataSource.getRepository<ExtensionRow>(extensionEntity);
|
|
185
|
+
const owners = dataSource.getRepository(OmniRecordEntity);
|
|
186
|
+
service = new OmniExtensionProjectionService(
|
|
187
|
+
extensions,
|
|
188
|
+
owners,
|
|
189
|
+
dataSource,
|
|
190
|
+
alphaScope,
|
|
191
|
+
dialect,
|
|
192
|
+
events as never,
|
|
193
|
+
definition,
|
|
194
|
+
);
|
|
195
|
+
bravoService = new OmniExtensionProjectionService(
|
|
196
|
+
extensions,
|
|
197
|
+
owners,
|
|
198
|
+
dataSource,
|
|
199
|
+
bravoScope,
|
|
200
|
+
dialect,
|
|
201
|
+
events as never,
|
|
202
|
+
definition,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterEach(async () => {
|
|
207
|
+
if (dataSource.isInitialized) await dataSource.destroy();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("creates, reads, filters, sorts, patches, and isolates owner identity by scope and kind", async () => {
|
|
211
|
+
const first = await service.createEntity({
|
|
212
|
+
guid: firstGuid,
|
|
213
|
+
label: "first",
|
|
214
|
+
priority: 2,
|
|
215
|
+
enabled: true,
|
|
216
|
+
payload: { retained: true },
|
|
217
|
+
});
|
|
218
|
+
await service.createEntity({
|
|
219
|
+
guid: secondGuid,
|
|
220
|
+
label: "second",
|
|
221
|
+
priority: 1,
|
|
222
|
+
enabled: false,
|
|
223
|
+
});
|
|
224
|
+
await bravoService.createEntity({
|
|
225
|
+
guid: firstGuid,
|
|
226
|
+
label: "bravo copy",
|
|
227
|
+
priority: 8,
|
|
228
|
+
enabled: true,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(first).toMatchObject({
|
|
232
|
+
guid: firstGuid,
|
|
233
|
+
label: "first",
|
|
234
|
+
priority: 2,
|
|
235
|
+
revision: 1,
|
|
236
|
+
payload: { retained: true, attributes: { priority: 2, enabled: true } },
|
|
237
|
+
});
|
|
238
|
+
expect(await bravoService.getEntity({ guid: firstGuid })).toMatchObject({
|
|
239
|
+
label: "bravo copy",
|
|
240
|
+
priority: 8,
|
|
241
|
+
enabled: true,
|
|
242
|
+
revision: 1,
|
|
243
|
+
});
|
|
244
|
+
const [grid, count] = (await service.getEntityListExtended(
|
|
245
|
+
{
|
|
246
|
+
where: { filters: { priority: 2, enabled: true } },
|
|
247
|
+
order: { label: "DESC" },
|
|
248
|
+
} as never,
|
|
249
|
+
true,
|
|
250
|
+
)) as [ExtensionRow[], number];
|
|
251
|
+
expect(count).toBe(1);
|
|
252
|
+
expect(grid).toEqual([expect.objectContaining({ guid: firstGuid })]);
|
|
253
|
+
|
|
254
|
+
const updated = await service.updateEntity(
|
|
255
|
+
{ guid: firstGuid },
|
|
256
|
+
{
|
|
257
|
+
expectedRevision: 1,
|
|
258
|
+
priority: 3,
|
|
259
|
+
enabled: false,
|
|
260
|
+
label: "first updated",
|
|
261
|
+
},
|
|
262
|
+
);
|
|
263
|
+
expect(updated).toMatchObject({
|
|
264
|
+
label: "first updated",
|
|
265
|
+
priority: 3,
|
|
266
|
+
revision: 2,
|
|
267
|
+
payload: {
|
|
268
|
+
retained: true,
|
|
269
|
+
attributes: { priority: 3, enabled: false },
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
await expect(
|
|
273
|
+
service.updateEntity(
|
|
274
|
+
{ guid: firstGuid },
|
|
275
|
+
{ expectedRevision: 1, label: "stale" },
|
|
276
|
+
),
|
|
277
|
+
).rejects.toThrow("stale");
|
|
278
|
+
|
|
279
|
+
const owners = dataSource.getRepository(OmniRecordEntity);
|
|
280
|
+
await owners.insert({
|
|
281
|
+
scopeId: alphaScope.scopeId,
|
|
282
|
+
guid: thirdGuid,
|
|
283
|
+
title: "Other owner",
|
|
284
|
+
kind: "other_extension",
|
|
285
|
+
status: OmniRecordStatus.Active,
|
|
286
|
+
payloadSchemaId: "other.extension.v1",
|
|
287
|
+
payloadSchemaVersion: 1,
|
|
288
|
+
});
|
|
289
|
+
expect(await service.getEntity({ guid: thirdGuid })).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("rolls back owner creation when the extension insert fails and cascades extension cleanup on delete", async () => {
|
|
293
|
+
await service.createEntity({
|
|
294
|
+
guid: firstGuid,
|
|
295
|
+
label: "unique label",
|
|
296
|
+
priority: 1,
|
|
297
|
+
enabled: true,
|
|
298
|
+
});
|
|
299
|
+
const extensions = dataSource.getRepository<ExtensionRow>(extensionEntity);
|
|
300
|
+
await dataSource.query(
|
|
301
|
+
`CREATE UNIQUE INDEX "example_extension_projection_label_unique" ON "${definition.tableName}" ("label")`,
|
|
302
|
+
);
|
|
303
|
+
await expect(
|
|
304
|
+
service.createEntity({
|
|
305
|
+
guid: secondGuid,
|
|
306
|
+
label: "unique label",
|
|
307
|
+
priority: 2,
|
|
308
|
+
enabled: false,
|
|
309
|
+
}),
|
|
310
|
+
).rejects.toThrow("already exists");
|
|
311
|
+
expect(
|
|
312
|
+
await dataSource.getRepository(OmniRecordEntity).findOneBy({
|
|
313
|
+
scopeId: alphaScope.scopeId,
|
|
314
|
+
guid: secondGuid,
|
|
315
|
+
}),
|
|
316
|
+
).toBeNull();
|
|
317
|
+
|
|
318
|
+
await service.deleteEntity({ guid: firstGuid });
|
|
319
|
+
expect(
|
|
320
|
+
await dataSource.getRepository(OmniRecordEntity).countBy({
|
|
321
|
+
scopeId: alphaScope.scopeId,
|
|
322
|
+
guid: firstGuid,
|
|
323
|
+
}),
|
|
324
|
+
).toBe(0);
|
|
325
|
+
expect(
|
|
326
|
+
await extensions.countBy({
|
|
327
|
+
scopeId: alphaScope.scopeId,
|
|
328
|
+
guid: firstGuid,
|
|
329
|
+
}),
|
|
330
|
+
).toBe(0);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("rolls back the owner revision when the extension row disappears during a patch", async () => {
|
|
334
|
+
await service.createEntity({
|
|
335
|
+
guid: firstGuid,
|
|
336
|
+
label: "rollback candidate",
|
|
337
|
+
priority: 1,
|
|
338
|
+
enabled: true,
|
|
339
|
+
});
|
|
340
|
+
const patchValues = jest
|
|
341
|
+
.spyOn(dialect, "patchValues")
|
|
342
|
+
.mockResolvedValue(0);
|
|
343
|
+
|
|
344
|
+
await expect(
|
|
345
|
+
service.updateEntity(
|
|
346
|
+
{ guid: firstGuid },
|
|
347
|
+
{ expectedRevision: 1, label: "must roll back" },
|
|
348
|
+
),
|
|
349
|
+
).rejects.toThrow("extension row is unavailable");
|
|
350
|
+
expect(
|
|
351
|
+
await dataSource.getRepository(OmniRecordEntity).findOneByOrFail({
|
|
352
|
+
scopeId: alphaScope.scopeId,
|
|
353
|
+
guid: firstGuid,
|
|
354
|
+
}),
|
|
355
|
+
).toMatchObject({ revision: 1 });
|
|
356
|
+
patchValues.mockRestore();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("uses one generic relation table with fixed registered endpoint semantics and validates reusable external-ref targets", async () => {
|
|
360
|
+
await service.createEntity({
|
|
361
|
+
guid: firstGuid,
|
|
362
|
+
label: "source",
|
|
363
|
+
priority: 1,
|
|
364
|
+
enabled: true,
|
|
365
|
+
});
|
|
366
|
+
await service.createEntity({
|
|
367
|
+
guid: secondGuid,
|
|
368
|
+
label: "target",
|
|
369
|
+
priority: 2,
|
|
370
|
+
enabled: false,
|
|
371
|
+
});
|
|
372
|
+
await bravoService.createEntity({
|
|
373
|
+
guid: bravoOnlyGuid,
|
|
374
|
+
label: "bravo only",
|
|
375
|
+
priority: 3,
|
|
376
|
+
enabled: true,
|
|
377
|
+
});
|
|
378
|
+
const records = dataSource.getRepository(OmniRecordEntity);
|
|
379
|
+
const relations = new OmniRelationProjectionService(
|
|
380
|
+
dataSource.getRepository(OmniRelationEntity) as never,
|
|
381
|
+
alphaScope,
|
|
382
|
+
"hard",
|
|
383
|
+
records,
|
|
384
|
+
createOmniRelationKindContract(["links"]),
|
|
385
|
+
relationDefinition,
|
|
386
|
+
);
|
|
387
|
+
const relationGuid = "10000000-0000-4000-8000-000000000010";
|
|
388
|
+
await relations.createEntity({
|
|
389
|
+
guid: relationGuid,
|
|
390
|
+
sourceRecordId: firstGuid,
|
|
391
|
+
targetRecordId: secondGuid,
|
|
392
|
+
payload: { strength: 1 },
|
|
393
|
+
});
|
|
394
|
+
await expect(
|
|
395
|
+
relations.createEntity({
|
|
396
|
+
guid: "10000000-0000-4000-8000-000000000011",
|
|
397
|
+
sourceRecordId: firstGuid,
|
|
398
|
+
targetRecordId: bravoOnlyGuid,
|
|
399
|
+
}),
|
|
400
|
+
).rejects.toThrow("not found");
|
|
401
|
+
await expect(
|
|
402
|
+
relations.createEntity({
|
|
403
|
+
guid: "10000000-0000-4000-8000-000000000012",
|
|
404
|
+
sourceRecordId: firstGuid,
|
|
405
|
+
targetRecordId: secondGuid,
|
|
406
|
+
}),
|
|
407
|
+
).rejects.toThrow();
|
|
408
|
+
await expect(
|
|
409
|
+
relations.createEntity({
|
|
410
|
+
guid: "10000000-0000-4000-8000-000000000013",
|
|
411
|
+
sourceRecordId: firstGuid,
|
|
412
|
+
targetRecordId: secondGuid,
|
|
413
|
+
kind: "links",
|
|
414
|
+
}),
|
|
415
|
+
).rejects.toThrow("server-owned");
|
|
416
|
+
const otherRelationGuid = "10000000-0000-4000-8000-000000000014";
|
|
417
|
+
await dataSource.getRepository(OmniRelationEntity).insert({
|
|
418
|
+
scopeId: alphaScope.scopeId,
|
|
419
|
+
guid: otherRelationGuid,
|
|
420
|
+
sourceRecordId: firstGuid,
|
|
421
|
+
targetRecordId: secondGuid,
|
|
422
|
+
kind: "other_link",
|
|
423
|
+
status: "active",
|
|
424
|
+
payloadSchemaId: "other.link.v1",
|
|
425
|
+
payloadSchemaVersion: 1,
|
|
426
|
+
});
|
|
427
|
+
const otherSchemaRelationGuid =
|
|
428
|
+
"10000000-0000-4000-8000-000000000015";
|
|
429
|
+
await dataSource.getRepository(OmniRelationEntity).insert({
|
|
430
|
+
scopeId: alphaScope.scopeId,
|
|
431
|
+
guid: otherSchemaRelationGuid,
|
|
432
|
+
sourceRecordId: secondGuid,
|
|
433
|
+
targetRecordId: firstGuid,
|
|
434
|
+
kind: "links",
|
|
435
|
+
status: "active",
|
|
436
|
+
payloadSchemaId: "other.link.v1",
|
|
437
|
+
payloadSchemaVersion: 1,
|
|
438
|
+
});
|
|
439
|
+
expect(
|
|
440
|
+
await relations.getEntity({ guid: otherRelationGuid }),
|
|
441
|
+
).toBeNull();
|
|
442
|
+
expect(await relations.getEntity(otherRelationGuid as never)).toBeNull();
|
|
443
|
+
expect(
|
|
444
|
+
await relations.getEntity({ guid: otherSchemaRelationGuid }),
|
|
445
|
+
).toBeNull();
|
|
446
|
+
await expect(
|
|
447
|
+
relations.getEntity({ guid: relationGuid, kind: "links" } as never),
|
|
448
|
+
).rejects.toThrow("server-owned");
|
|
449
|
+
await expect(
|
|
450
|
+
relations.updateEntity(
|
|
451
|
+
{ guid: otherRelationGuid },
|
|
452
|
+
{ payload: { no: true } },
|
|
453
|
+
),
|
|
454
|
+
).rejects.toThrow();
|
|
455
|
+
await expect(
|
|
456
|
+
relations.deleteEntity({ guid: otherRelationGuid }),
|
|
457
|
+
).rejects.toThrow();
|
|
458
|
+
await expect(
|
|
459
|
+
relations.getEntityListExtended({
|
|
460
|
+
where: { filters: { kind: "other_link" } },
|
|
461
|
+
} as never),
|
|
462
|
+
).rejects.toThrow("server-owned");
|
|
463
|
+
await expect(
|
|
464
|
+
relations.updateEntity(
|
|
465
|
+
{ guid: relationGuid, kind: "links" } as never,
|
|
466
|
+
{ payload: { no: true }, expectedRevision: 1 },
|
|
467
|
+
),
|
|
468
|
+
).rejects.toThrow("server-owned");
|
|
469
|
+
await expect(
|
|
470
|
+
relations.deleteEntity({ guid: relationGuid, kind: "links" } as never),
|
|
471
|
+
).rejects.toThrow("server-owned");
|
|
472
|
+
expect(await relations.getEntityListExtended({})).toEqual([
|
|
473
|
+
expect.objectContaining({ guid: relationGuid }),
|
|
474
|
+
]);
|
|
475
|
+
expect(
|
|
476
|
+
dataSource.entityMetadatas.filter(
|
|
477
|
+
(metadata) => metadata.tableName === "omni-relation",
|
|
478
|
+
),
|
|
479
|
+
).toHaveLength(1);
|
|
480
|
+
|
|
481
|
+
const validator = new OmniExternalRefBindingValidator(
|
|
482
|
+
records,
|
|
483
|
+
alphaScope,
|
|
484
|
+
);
|
|
485
|
+
await expect(
|
|
486
|
+
validator.assertTarget({
|
|
487
|
+
internalId: firstGuid,
|
|
488
|
+
internalType: OmniExternalRefInternalType.Record,
|
|
489
|
+
}),
|
|
490
|
+
).resolves.toMatchObject({ guid: firstGuid });
|
|
491
|
+
await expect(
|
|
492
|
+
new OmniExternalRefBindingValidator(records, bravoScope).assertTarget({
|
|
493
|
+
internalId: firstGuid,
|
|
494
|
+
internalType: OmniExternalRefInternalType.Record,
|
|
495
|
+
}),
|
|
496
|
+
).rejects.toThrow("not found");
|
|
497
|
+
|
|
498
|
+
const externalRefs = new OmniExternalRefService(
|
|
499
|
+
dataSource.getRepository(OmniExternalRefEntity) as never,
|
|
500
|
+
alphaScope,
|
|
501
|
+
"hard",
|
|
502
|
+
validator,
|
|
503
|
+
);
|
|
504
|
+
const externalRefGuid = "10000000-0000-4000-8000-000000000020";
|
|
505
|
+
await externalRefs.createEntity({
|
|
506
|
+
guid: externalRefGuid,
|
|
507
|
+
provider: "example",
|
|
508
|
+
externalId: "one",
|
|
509
|
+
internalType: OmniExternalRefInternalType.Record,
|
|
510
|
+
internalId: firstGuid,
|
|
511
|
+
});
|
|
512
|
+
await expect(
|
|
513
|
+
externalRefs.createEntity({
|
|
514
|
+
guid: "10000000-0000-4000-8000-000000000021",
|
|
515
|
+
provider: "example",
|
|
516
|
+
externalId: "missing",
|
|
517
|
+
internalType: OmniExternalRefInternalType.Record,
|
|
518
|
+
internalId: thirdGuid,
|
|
519
|
+
}),
|
|
520
|
+
).rejects.toThrow("not found");
|
|
521
|
+
await expect(
|
|
522
|
+
new OmniExternalRefService(
|
|
523
|
+
dataSource.getRepository(OmniExternalRefEntity) as never,
|
|
524
|
+
bravoScope,
|
|
525
|
+
"hard",
|
|
526
|
+
new OmniExternalRefBindingValidator(records, bravoScope),
|
|
527
|
+
).createEntity({
|
|
528
|
+
guid: "10000000-0000-4000-8000-000000000022",
|
|
529
|
+
provider: "example",
|
|
530
|
+
externalId: "cross-scope",
|
|
531
|
+
internalType: OmniExternalRefInternalType.Record,
|
|
532
|
+
internalId: firstGuid,
|
|
533
|
+
}),
|
|
534
|
+
).rejects.toThrow("not found");
|
|
535
|
+
await expect(
|
|
536
|
+
externalRefs.updateEntity(
|
|
537
|
+
{ guid: externalRefGuid },
|
|
538
|
+
{ internalId: bravoOnlyGuid },
|
|
539
|
+
),
|
|
540
|
+
).rejects.toThrow("immutable");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('supports one multi-kind relation surface, public aliases, revisions, and bounded catalog readers', async () => {
|
|
544
|
+
await service.createEntity({
|
|
545
|
+
guid: firstGuid,
|
|
546
|
+
label: 'source',
|
|
547
|
+
priority: 1,
|
|
548
|
+
enabled: true,
|
|
549
|
+
});
|
|
550
|
+
await service.createEntity({
|
|
551
|
+
guid: secondGuid,
|
|
552
|
+
label: 'target',
|
|
553
|
+
priority: 2,
|
|
554
|
+
enabled: false,
|
|
555
|
+
});
|
|
556
|
+
const multiDefinition = defineOmniRelationProjection({
|
|
557
|
+
id: 'example.multi-link.v1',
|
|
558
|
+
relation: {
|
|
559
|
+
allowedKinds: ['links', 'blocks'],
|
|
560
|
+
sourceKind: definition.owner.kind,
|
|
561
|
+
targetKind: definition.owner.kind,
|
|
562
|
+
schema: { id: 'example.multi-link.v1', version: 1 },
|
|
563
|
+
},
|
|
564
|
+
aliases: {
|
|
565
|
+
kind: 'linkKind',
|
|
566
|
+
source: 'sourceWorkItemId',
|
|
567
|
+
target: 'targetWorkItemId',
|
|
568
|
+
payload: 'metadata',
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
const relations = new OmniRelationProjectionService(
|
|
572
|
+
dataSource.getRepository(OmniRelationEntity),
|
|
573
|
+
alphaScope,
|
|
574
|
+
'hard',
|
|
575
|
+
dataSource.getRepository(OmniRecordEntity),
|
|
576
|
+
createOmniRelationKindContract(['links', 'blocks']),
|
|
577
|
+
multiDefinition,
|
|
578
|
+
dataSource,
|
|
579
|
+
);
|
|
580
|
+
const relationGuid = '10000000-0000-4000-8000-000000000030';
|
|
581
|
+
const relation = await relations.createEntity({
|
|
582
|
+
guid: relationGuid,
|
|
583
|
+
linkKind: 'links',
|
|
584
|
+
sourceWorkItemId: firstGuid,
|
|
585
|
+
targetWorkItemId: secondGuid,
|
|
586
|
+
metadata: { enabled: true },
|
|
587
|
+
} as never);
|
|
588
|
+
expect(relation).toMatchObject({
|
|
589
|
+
linkKind: 'links',
|
|
590
|
+
sourceWorkItemId: firstGuid,
|
|
591
|
+
targetWorkItemId: secondGuid,
|
|
592
|
+
metadata: { enabled: true },
|
|
593
|
+
revision: 1,
|
|
594
|
+
});
|
|
595
|
+
await expect(
|
|
596
|
+
relations.createEntity({
|
|
597
|
+
guid: '10000000-0000-4000-8000-000000000031',
|
|
598
|
+
linkKind: 'not_registered',
|
|
599
|
+
sourceWorkItemId: firstGuid,
|
|
600
|
+
targetWorkItemId: secondGuid,
|
|
601
|
+
} as never),
|
|
602
|
+
).rejects.toThrow('not allowed');
|
|
603
|
+
await expect(
|
|
604
|
+
relations.updateEntity(
|
|
605
|
+
{ guid: relationGuid },
|
|
606
|
+
{ sourceWorkItemId: secondGuid, expectedRevision: 1 } as never,
|
|
607
|
+
),
|
|
608
|
+
).rejects.toThrow('immutable');
|
|
609
|
+
const updated = await relations.updateEntity(
|
|
610
|
+
{ guid: relationGuid },
|
|
611
|
+
{ metadata: { enabled: false }, expectedRevision: 1 } as never,
|
|
612
|
+
);
|
|
613
|
+
expect(updated).toMatchObject({
|
|
614
|
+
metadata: { enabled: false },
|
|
615
|
+
revision: 2,
|
|
616
|
+
});
|
|
617
|
+
await expect(
|
|
618
|
+
relations.updateEntity(
|
|
619
|
+
{ guid: relationGuid },
|
|
620
|
+
{ metadata: { enabled: true }, expectedRevision: 1 } as never,
|
|
621
|
+
),
|
|
622
|
+
).rejects.toThrow('revision conflict');
|
|
623
|
+
|
|
624
|
+
const catalog = createOmniProjectionReaderCatalog([
|
|
625
|
+
{
|
|
626
|
+
type: 'extension',
|
|
627
|
+
id: definition.id,
|
|
628
|
+
entity: extensionEntity,
|
|
629
|
+
definition,
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
type: 'relation',
|
|
633
|
+
id: multiDefinition.id,
|
|
634
|
+
definition: multiDefinition,
|
|
635
|
+
},
|
|
636
|
+
]);
|
|
637
|
+
const readers = catalog.bind(dataSource.manager, alphaScope);
|
|
638
|
+
const projectedExtensions = await readers.extension(definition.id).list({
|
|
639
|
+
where: { label: 'source' },
|
|
640
|
+
take: 1,
|
|
641
|
+
});
|
|
642
|
+
expect(projectedExtensions).toEqual([
|
|
643
|
+
expect.objectContaining({
|
|
644
|
+
scopeId: alphaScope.scopeId,
|
|
645
|
+
guid: firstGuid,
|
|
646
|
+
label: 'source',
|
|
647
|
+
revision: 1,
|
|
648
|
+
createdAt: expect.any(Date),
|
|
649
|
+
updatedAt: expect.any(Date),
|
|
650
|
+
}),
|
|
651
|
+
]);
|
|
652
|
+
expect(projectedExtensions[0]).not.toHaveProperty('kind');
|
|
653
|
+
await expect(
|
|
654
|
+
readers.extension(definition.id).list({ where: { payload: {} } }),
|
|
655
|
+
).rejects.toThrow('declared eq fields');
|
|
656
|
+
await expect(
|
|
657
|
+
readers.relation(multiDefinition.id).list({
|
|
658
|
+
sourceRecordId: firstGuid,
|
|
659
|
+
take: 1,
|
|
660
|
+
}),
|
|
661
|
+
).resolves.toEqual([expect.objectContaining({ guid: relationGuid })]);
|
|
662
|
+
await expect(
|
|
663
|
+
readers.relation(multiDefinition.id).list({ take: 0 }),
|
|
664
|
+
).rejects.toThrow('take must be an integer');
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('maps concurrent revision and lifecycle-predicate writes to one success and one conflict', async () => {
|
|
668
|
+
const sqlitePath = join(tmpdir(), `omni-projection-race-${randomUUID()}.sqlite`);
|
|
669
|
+
const options = {
|
|
670
|
+
type: name,
|
|
671
|
+
...(name === 'sqlite'
|
|
672
|
+
? { database: sqlitePath }
|
|
673
|
+
: { url: url!, ssl: false }),
|
|
674
|
+
entities: [
|
|
675
|
+
OmniNamedEntity,
|
|
676
|
+
OmniRecordEntity,
|
|
677
|
+
OmniRelationEntity,
|
|
678
|
+
OmniExternalRefEntity,
|
|
679
|
+
extensionEntity,
|
|
680
|
+
],
|
|
681
|
+
} as const;
|
|
682
|
+
const primary = new DataSource({
|
|
683
|
+
...options,
|
|
684
|
+
dropSchema: true,
|
|
685
|
+
synchronize: true,
|
|
686
|
+
} as never);
|
|
687
|
+
const secondary = new DataSource({
|
|
688
|
+
...options,
|
|
689
|
+
synchronize: false,
|
|
690
|
+
} as never);
|
|
691
|
+
await primary.initialize();
|
|
692
|
+
await secondary.initialize();
|
|
693
|
+
try {
|
|
694
|
+
const first = new OmniExtensionProjectionService(
|
|
695
|
+
primary.getRepository<ExtensionRow>(extensionEntity),
|
|
696
|
+
primary.getRepository(OmniRecordEntity),
|
|
697
|
+
primary,
|
|
698
|
+
alphaScope,
|
|
699
|
+
createProjectionDialect(name),
|
|
700
|
+
events as never,
|
|
701
|
+
definition,
|
|
702
|
+
);
|
|
703
|
+
const second = new OmniExtensionProjectionService(
|
|
704
|
+
secondary.getRepository<ExtensionRow>(extensionEntity),
|
|
705
|
+
secondary.getRepository(OmniRecordEntity),
|
|
706
|
+
secondary,
|
|
707
|
+
alphaScope,
|
|
708
|
+
createProjectionDialect(name),
|
|
709
|
+
events as never,
|
|
710
|
+
definition,
|
|
711
|
+
);
|
|
712
|
+
await first.createEntity({
|
|
713
|
+
guid: firstGuid,
|
|
714
|
+
label: 'revision-race',
|
|
715
|
+
enabled: true,
|
|
716
|
+
});
|
|
717
|
+
const revisionOutcomes = await Promise.allSettled([
|
|
718
|
+
first.updateEntity(
|
|
719
|
+
{ guid: firstGuid },
|
|
720
|
+
{ expectedRevision: 1, label: 'revision race one' },
|
|
721
|
+
),
|
|
722
|
+
second.updateEntity(
|
|
723
|
+
{ guid: firstGuid },
|
|
724
|
+
{ expectedRevision: 1, label: 'revision race two' },
|
|
725
|
+
),
|
|
726
|
+
]);
|
|
727
|
+
expect(
|
|
728
|
+
revisionOutcomes.filter((result) => result.status === 'fulfilled'),
|
|
729
|
+
).toHaveLength(1);
|
|
730
|
+
expect(
|
|
731
|
+
revisionOutcomes.filter((result) => result.status === 'rejected'),
|
|
732
|
+
).toHaveLength(1);
|
|
733
|
+
|
|
734
|
+
let waiting = 0;
|
|
735
|
+
let release: (() => void) | undefined;
|
|
736
|
+
const gate = new Promise<void>((resolve) => {
|
|
737
|
+
release = resolve;
|
|
738
|
+
});
|
|
739
|
+
const lifecycle = {
|
|
740
|
+
beforeCreate: async (context: any) => {
|
|
741
|
+
const existing = await context.readers.extension(definition.id).list({
|
|
742
|
+
where: { label: 'predicate-singleton' },
|
|
743
|
+
take: 1,
|
|
744
|
+
});
|
|
745
|
+
if (existing.length > 0) {
|
|
746
|
+
throw new ConflictException('Lifecycle predicate already holds.');
|
|
747
|
+
}
|
|
748
|
+
waiting += 1;
|
|
749
|
+
if (waiting === 2) release?.();
|
|
750
|
+
await gate;
|
|
751
|
+
},
|
|
752
|
+
};
|
|
753
|
+
const guardedFirst = new OmniExtensionProjectionService(
|
|
754
|
+
primary.getRepository<ExtensionRow>(extensionEntity),
|
|
755
|
+
primary.getRepository(OmniRecordEntity),
|
|
756
|
+
primary,
|
|
757
|
+
alphaScope,
|
|
758
|
+
createProjectionDialect(name),
|
|
759
|
+
events as never,
|
|
760
|
+
definition,
|
|
761
|
+
lifecycle,
|
|
762
|
+
);
|
|
763
|
+
const guardedSecond = new OmniExtensionProjectionService(
|
|
764
|
+
secondary.getRepository<ExtensionRow>(extensionEntity),
|
|
765
|
+
secondary.getRepository(OmniRecordEntity),
|
|
766
|
+
secondary,
|
|
767
|
+
alphaScope,
|
|
768
|
+
createProjectionDialect(name),
|
|
769
|
+
events as never,
|
|
770
|
+
definition,
|
|
771
|
+
lifecycle,
|
|
772
|
+
);
|
|
773
|
+
const lifecycleOutcomes = await Promise.allSettled([
|
|
774
|
+
guardedFirst.createEntity({
|
|
775
|
+
guid: secondGuid,
|
|
776
|
+
label: 'predicate-singleton',
|
|
777
|
+
enabled: true,
|
|
778
|
+
}),
|
|
779
|
+
guardedSecond.createEntity({
|
|
780
|
+
guid: thirdGuid,
|
|
781
|
+
label: 'predicate-singleton',
|
|
782
|
+
enabled: false,
|
|
783
|
+
}),
|
|
784
|
+
]);
|
|
785
|
+
expect(
|
|
786
|
+
lifecycleOutcomes.filter((result) => result.status === 'fulfilled'),
|
|
787
|
+
).toHaveLength(1);
|
|
788
|
+
expect(
|
|
789
|
+
lifecycleOutcomes.filter((result) => result.status === 'rejected'),
|
|
790
|
+
).toHaveLength(1);
|
|
791
|
+
expect(
|
|
792
|
+
lifecycleOutcomes.find(
|
|
793
|
+
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
|
794
|
+
)!.reason,
|
|
795
|
+
).toBeInstanceOf(ConflictException);
|
|
796
|
+
} finally {
|
|
797
|
+
if (secondary.isInitialized) await secondary.destroy();
|
|
798
|
+
if (primary.isInitialized) {
|
|
799
|
+
await primary.dropDatabase();
|
|
800
|
+
await primary.destroy();
|
|
801
|
+
}
|
|
802
|
+
if (name === 'sqlite') await rm(sqlitePath, { force: true });
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
},
|
|
806
|
+
);
|