@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.
Files changed (74) hide show
  1. package/package.json +17 -15
  2. package/src/__tests__/omni-collection.entity.spec.ts +49 -49
  3. package/src/__tests__/omni-collection.service.spec.ts +83 -83
  4. package/src/__tests__/omni-document.entity.spec.ts +55 -55
  5. package/src/__tests__/omni-document.service.spec.ts +83 -83
  6. package/src/__tests__/omni-extension-projection-constraints.spec.ts +518 -518
  7. package/src/__tests__/omni-extension-projection.registration.spec.ts +947 -946
  8. package/src/__tests__/omni-extension-projection.service.spec.ts +806 -806
  9. package/src/__tests__/omni-external-ref.entity.spec.ts +36 -36
  10. package/src/__tests__/omni-external-ref.service.spec.ts +252 -252
  11. package/src/__tests__/omni-named.entity.spec.ts +33 -33
  12. package/src/__tests__/omni-record.entity.spec.ts +32 -32
  13. package/src/__tests__/omni-record.service.spec.ts +107 -107
  14. package/src/__tests__/omni-relation-semantics.spec.ts +102 -102
  15. package/src/__tests__/omni-relation.entity.spec.ts +38 -38
  16. package/src/__tests__/omni-scope-substrate.spec.ts +81 -81
  17. package/src/__tests__/omni-scoped.backend.spec.ts +115 -115
  18. package/src/__tests__/omni-scoped.service.spec.ts +354 -354
  19. package/src/__tests__/omnikernel.diagnostics.spec.ts +94 -94
  20. package/src/__tests__/omnikernel.module.spec.ts +38 -40
  21. package/src/__tests__/omnikernel.persistence.spec.ts +154 -154
  22. package/src/__tests__/omnikernel.public-api.spec.ts +238 -238
  23. package/src/__tests__/omnikernel.query.service.spec.ts +155 -155
  24. package/src/base/omni-base.entity.ts +41 -41
  25. package/src/base/omni-external-ref.entity.ts +53 -53
  26. package/src/base/omni-named.entity.ts +16 -16
  27. package/src/base/omni-record.entity.ts +51 -51
  28. package/src/base/omni-relation.entity.ts +72 -72
  29. package/src/index.ts +49 -49
  30. package/src/omni-collection-kind.enum.ts +10 -10
  31. package/src/omni-collection.backend.ts +16 -16
  32. package/src/omni-collection.dto.ts +170 -170
  33. package/src/omni-collection.entity.ts +21 -21
  34. package/src/omni-collection.service.ts +75 -75
  35. package/src/omni-document-kind.enum.ts +12 -12
  36. package/src/omni-document.backend.ts +16 -16
  37. package/src/omni-document.dto.ts +198 -198
  38. package/src/omni-document.entity.ts +30 -30
  39. package/src/omni-document.service.ts +75 -75
  40. package/src/omni-dto.helpers.ts +21 -21
  41. package/src/omni-extension-projection.definition.ts +294 -294
  42. package/src/omni-extension-projection.resource.ts +274 -274
  43. package/src/omni-extension-projection.service.ts +452 -452
  44. package/src/omni-external-ref-binding.validator.ts +53 -53
  45. package/src/omni-external-ref-internal-type.enum.ts +11 -11
  46. package/src/omni-external-ref.backend.ts +25 -25
  47. package/src/omni-external-ref.dto.ts +128 -128
  48. package/src/omni-external-ref.service.ts +255 -255
  49. package/src/omni-migration.ts +365 -365
  50. package/src/omni-named.backend.ts +11 -11
  51. package/src/omni-named.dto.ts +86 -86
  52. package/src/omni-projection.catalog.ts +345 -345
  53. package/src/omni-projection.lifecycle.ts +53 -53
  54. package/src/omni-record-status.enum.ts +11 -11
  55. package/src/omni-record.backend.ts +19 -19
  56. package/src/omni-record.dto.ts +157 -157
  57. package/src/omni-record.service.ts +113 -113
  58. package/src/omni-relation-kind.contract.ts +49 -49
  59. package/src/omni-relation-kind.enum.ts +12 -12
  60. package/src/omni-relation-projection.definition.ts +166 -166
  61. package/src/omni-relation-projection.resource.ts +360 -360
  62. package/src/omni-relation-projection.service.ts +610 -610
  63. package/src/omni-relation-semantics.ts +52 -52
  64. package/src/omni-relation-status.enum.ts +11 -11
  65. package/src/omni-relation.backend.ts +21 -21
  66. package/src/omni-relation.dto.ts +142 -142
  67. package/src/omni-relation.service.ts +159 -159
  68. package/src/omni-scope.ts +161 -161
  69. package/src/omni-scoped.backend.ts +110 -110
  70. package/src/omni-scoped.repository.ts +34 -34
  71. package/src/omni-scoped.service.ts +338 -338
  72. package/src/omnikernel.diagnostics.ts +83 -83
  73. package/src/omnikernel.module.ts +97 -97
  74. package/src/omnikernel.query.service.ts +108 -108
@@ -1,946 +1,947 @@
1
- import { Scope, type INestApplication } from '@nestjs/common';
2
- import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals';
3
- import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
4
- import { EventEmitterModule } from '@nestjs/event-emitter';
5
- import { GraphQLModule, GraphQLSchemaHost } from '@nestjs/graphql';
6
- import { ModuleRef } from '@nestjs/core';
7
- import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm';
8
- import { Test } from '@nestjs/testing';
9
- import {
10
- ConflictException,
11
- NotFoundException,
12
- } from '@nestjs/common';
13
- import {
14
- createProjectionDialect,
15
- CrudGenResourceFactory,
16
- } from "@nest-yalc-2/crud-gen";
17
- import { EventModule } from '@nest-yalc-2/event-manager';
18
- import { UUIDScalar } from '@nest-yalc-2/graphql/scalars/uuid.scalar.js';
19
- import request from 'supertest';
20
- import { DataSource } from "typeorm";
21
- import type { OmniMigrationRunner } from '../omni-migration.js';
22
-
23
- import { OmniNamedEntity } from "../base/omni-named.entity.js";
24
- import { OmniRecordEntity } from "../base/omni-record.entity.js";
25
- import { OmniRelationEntity } from "../base/omni-relation.entity.js";
26
- import { OmniExternalRefEntity } from "../base/omni-external-ref.entity.js";
27
- import { OmniCollectionEntity } from "../omni-collection.entity.js";
28
- import { OmniDocumentEntity } from "../omni-document.entity.js";
29
- import { OmniRecordStatus } from "../omni-record-status.enum.js";
30
- import { OmniKernelModule } from '../omnikernel.module.js';
31
- import {
32
- OmniRecordCondition,
33
- OmniRecordCreateInput,
34
- OmniRecordType,
35
- OmniRecordUpdateInput,
36
- } from '../omni-record.dto.js';
37
- import {
38
- createOmniExtensionProjectionEntity,
39
- defineOmniExtensionProjection,
40
- } from "../omni-extension-projection.definition.js";
41
- import { createOmniExtensionProjectionRegistration } from "../omni-extension-projection.resource.js";
42
- import { defineOmniRelationProjection } from '../omni-relation-projection.definition.js';
43
- import { createOmniRelationProjectionRegistration } from '../omni-relation-projection.resource.js';
44
- import {
45
- captureOmniMigrationSnapshot,
46
- createOmniMigrationPlan,
47
- defineOmniMigrationSnapshot,
48
- } from "../omni-migration.js";
49
- import { createOmniProjectionReaderCatalogProvider } from '../omni-projection.catalog.js';
50
-
51
- type ExtensionRow = {
52
- scopeId: string;
53
- guid: string;
54
- label: string;
55
- payload: Record<string, unknown>;
56
- };
57
-
58
- class ExampleExtensionApi {
59
- guid!: string;
60
- label!: string;
61
- enabled!: boolean;
62
- revision!: number;
63
- payload!: Record<string, unknown>;
64
- }
65
-
66
- class ExampleLinkApi {
67
- guid!: string;
68
- sourceRecordId!: string;
69
- targetRecordId!: string;
70
- revision!: number;
71
- payload!: Record<string, unknown>;
72
- }
73
-
74
- const rawRecordResource = CrudGenResourceFactory<typeof OmniRecordEntity>({
75
- entityModel: OmniRecordEntity,
76
- backend: false,
77
- graphql: {
78
- resolver: {
79
- dto: OmniRecordType,
80
- input: {
81
- create: OmniRecordCreateInput,
82
- update: OmniRecordUpdateInput,
83
- conditions: OmniRecordCondition,
84
- },
85
- prefix: 'Raw_',
86
- },
87
- },
88
- rest: {
89
- dto: OmniRecordType,
90
- path: 'raw-records',
91
- idField: 'guid',
92
- },
93
- });
94
-
95
- const definition = defineOmniExtensionProjection({
96
- id: "example.nest-extension.v1",
97
- tableName: "example-nest-extension",
98
- identity: { column: "guid", uniqueWithinScope: true },
99
- scope: { column: "scopeId", serverOwned: true },
100
- revision: { column: "revision" },
101
- payload: { column: "payload", allowCreate: true },
102
- deletion: "hard",
103
- fields: [
104
- {
105
- name: "guid",
106
- storage: "column",
107
- column: "guid",
108
- codec: "uuid",
109
- nullable: false,
110
- requiredOnCreate: true,
111
- },
112
- {
113
- name: "label",
114
- storage: "column",
115
- column: "label",
116
- codec: "string",
117
- nullable: false,
118
- requiredOnCreate: true,
119
- query: { filter: ["eq"], sort: true },
120
- },
121
- {
122
- name: "enabled",
123
- storage: "json",
124
- path: ["state", "enabled"],
125
- codec: "boolean",
126
- nullable: false,
127
- requiredOnCreate: true,
128
- index: { name: "example_nest_extension_enabled_idx" },
129
- query: { filter: ["eq"], sort: true },
130
- },
131
- ],
132
- owner: {
133
- kind: "example_nest_extension",
134
- title: "Example Nest extension",
135
- status: OmniRecordStatus.Active,
136
- schema: { id: "example.nest-extension.v1", version: 1 },
137
- },
138
- });
139
-
140
- const relationDefinition = defineOmniRelationProjection({
141
- id: 'example.nest-extension-link.v1',
142
- relation: {
143
- allowedKinds: ['example_link', 'example_blocks'],
144
- sourceKind: definition.owner.kind,
145
- targetKind: definition.owner.kind,
146
- schema: { id: 'example.nest-extension-link.v1', version: 1 },
147
- },
148
- aliases: {
149
- kind: 'linkKind',
150
- source: 'sourceWorkItemId',
151
- target: 'targetWorkItemId',
152
- payload: 'metadata',
153
- },
154
- });
155
-
156
- const scope = {
157
- scopeId: "scope-registration",
158
- cacheKey: (key: string) => `scope-registration:${key}`,
159
- };
160
-
161
- const events = {
162
- errorBadRequest: (_event: string, options: any) =>
163
- new ConflictException(options.response.message),
164
- errorConflict: (_event: string, options: any) =>
165
- new ConflictException(options.response.message),
166
- errorNotFound: (_event: string, options: any) =>
167
- new NotFoundException(options.response.message),
168
- };
169
-
170
- const lifecycleToken = Symbol('example-projection-lifecycle');
171
- const catalogToken = Symbol('example-projection-catalog');
172
- const moduleRefToken = Symbol('example-consumer-module-ref');
173
- const lifecycleBeforeCreate = jest.fn();
174
- const lifecycleBeforeUpdate = jest.fn();
175
- const lifecycleBeforeDelete = jest.fn();
176
-
177
- const postgresUrl = process.env.OMNI_EXTENSION_PROJECTION_POSTGRES_URL;
178
- const dialectCases: Array<{ name: "sqlite" | "postgres"; url?: string }> = [
179
- { name: "sqlite" },
180
- ...(postgresUrl ? [{ name: "postgres" as const, url: postgresUrl }] : []),
181
- ];
182
-
183
- describe("Omni extension projection registration", () => {
184
- it("returns generated CrudGen REST and GraphQL surfaces with request-scoped providers", () => {
185
- const entity = createOmniExtensionProjectionEntity<ExtensionRow>(
186
- definition,
187
- createProjectionDialect("sqlite"),
188
- );
189
- const registration = createOmniExtensionProjectionRegistration({
190
- entity,
191
- apiModel: ExampleExtensionApi,
192
- definition,
193
- dbConnection: "default",
194
- rest: { path: "example-extensions" },
195
- });
196
-
197
- expect(Object.isFrozen(registration)).toBe(true);
198
- expect(registration.definition).toBe(definition);
199
- expect(registration.entities).toEqual([entity]);
200
- expect(registration.reservedRecordKinds).toEqual([definition.owner.kind]);
201
- expect(registration.controllers).toHaveLength(1);
202
- expect(registration.resource.providers).toHaveLength(1);
203
- expect(registration.providers).toHaveLength(3);
204
- expect(
205
- registration.providers.filter(
206
- (provider) =>
207
- typeof provider === 'object' &&
208
- provider !== null &&
209
- 'provide' in provider &&
210
- provider.provide === registration.serviceToken,
211
- ),
212
- ).toHaveLength(1);
213
- expect(
214
- registration.providers.filter(
215
- (provider) =>
216
- typeof provider === 'object' &&
217
- provider !== null &&
218
- 'provide' in provider &&
219
- provider.provide === registration.dataLoaderToken,
220
- ),
221
- ).toHaveLength(1);
222
-
223
- const relationRegistration = createOmniRelationProjectionRegistration({
224
- apiModel: ExampleLinkApi,
225
- definition: relationDefinition,
226
- dbConnection: 'default',
227
- rest: { path: 'example-links' },
228
- });
229
- expect(Object.isFrozen(relationRegistration)).toBe(true);
230
- expect(relationRegistration.entities).toEqual([]);
231
- expect(relationRegistration.relationKinds).toEqual([
232
- 'example_link',
233
- 'example_blocks',
234
- ]);
235
- expect(relationRegistration.controllers).toHaveLength(1);
236
- expect(relationRegistration.providers).toHaveLength(3);
237
- });
238
- });
239
-
240
- describe('Omni extension projection generated transport', () => {
241
- let app: INestApplication;
242
- let dataSource: DataSource;
243
- const restGuid = '10000000-0000-4000-8000-000000000201';
244
- const graphqlGuid = '10000000-0000-4000-8000-000000000202';
245
- const relationGuid = '10000000-0000-4000-8000-000000000206';
246
- const normalRecordGuid = '10000000-0000-4000-8000-000000000203';
247
- const rejectedRecordGuid = '10000000-0000-4000-8000-000000000204';
248
-
249
- beforeAll(async () => {
250
- const entity = createOmniExtensionProjectionEntity<ExtensionRow>(
251
- definition,
252
- createProjectionDialect('sqlite'),
253
- );
254
- const registration = createOmniExtensionProjectionRegistration({
255
- entity,
256
- apiModel: ExampleExtensionApi,
257
- definition,
258
- dbConnection: 'default',
259
- catalog: { token: catalogToken },
260
- lifecycle: { token: lifecycleToken },
261
- moduleRefToken,
262
- rest: { path: 'example-extensions' },
263
- graphql: {
264
- names: {
265
- object: 'TransportExampleExtensionApi',
266
- create: 'TransportExampleExtensionApiCreateInput',
267
- patch: 'TransportExampleExtensionApiUpdateInput',
268
- conditions: 'TransportExampleExtensionApiCondition',
269
- },
270
- },
271
- });
272
- const relationRegistration = createOmniRelationProjectionRegistration({
273
- apiModel: ExampleLinkApi,
274
- definition: relationDefinition,
275
- dbConnection: 'default',
276
- catalog: { token: catalogToken },
277
- lifecycle: { token: lifecycleToken },
278
- moduleRefToken,
279
- rest: { path: 'example-links' },
280
- graphql: {
281
- names: {
282
- object: 'TransportExampleLinkApi',
283
- create: 'TransportExampleLinkApiCreateInput',
284
- patch: 'TransportExampleLinkApiUpdateInput',
285
- conditions: 'TransportExampleLinkApiCondition',
286
- },
287
- },
288
- });
289
- const moduleRef = await Test.createTestingModule({
290
- imports: [
291
- GraphQLModule.forRoot<ApolloDriverConfig>({
292
- driver: ApolloDriver,
293
- autoSchemaFile: true,
294
- path: '/graphql',
295
- }),
296
- EventEmitterModule.forRoot(),
297
- EventModule.forRootAsync(),
298
- TypeOrmModule.forRoot({
299
- type: 'sqlite',
300
- database: ':memory:',
301
- synchronize: false,
302
- autoLoadEntities: true,
303
- }),
304
- OmniKernelModule.register({
305
- dbConnection: 'default',
306
- defaultScopeId: scope.scopeId,
307
- reservedRecordKinds: registration.reservedRecordKinds,
308
- relationKinds: relationRegistration.relationKinds,
309
- }),
310
- TypeOrmModule.forFeature(registration.entities),
311
- ],
312
- controllers: [
313
- ...registration.controllers,
314
- ...relationRegistration.controllers,
315
- ...rawRecordResource.controllers,
316
- ],
317
- providers: [
318
- UUIDScalar,
319
- { provide: moduleRefToken, useExisting: ModuleRef },
320
- createOmniProjectionReaderCatalogProvider(
321
- [registration, relationRegistration],
322
- catalogToken,
323
- ),
324
- {
325
- provide: lifecycleToken,
326
- scope: Scope.REQUEST,
327
- useFactory: () => ({
328
- beforeCreate: async (context: any) => {
329
- lifecycleBeforeCreate(context);
330
- await context.readers.extension(definition.id).list({
331
- where: { label: '__missing__' },
332
- take: 1,
333
- });
334
- },
335
- beforeUpdate: async (context: any) => {
336
- lifecycleBeforeUpdate(context);
337
- },
338
- beforeDelete: async (context: any) => {
339
- lifecycleBeforeDelete(context);
340
- },
341
- }),
342
- },
343
- ...registration.providers,
344
- ...relationRegistration.providers,
345
- ...rawRecordResource.providers,
346
- ],
347
- }).compile();
348
-
349
- dataSource = moduleRef.get<DataSource>(getDataSourceToken());
350
- const snapshot = captureOmniMigrationSnapshot(
351
- 'example-nest-extension-transport-v1',
352
- dataSource,
353
- [registration],
354
- );
355
- const migration = createOmniMigrationPlan(snapshot);
356
- const queryRunner = dataSource.createQueryRunner();
357
- const migrationRunner: OmniMigrationRunner = queryRunner;
358
- await queryRunner.connect();
359
- try {
360
- await migration.create(migrationRunner);
361
- } finally {
362
- await queryRunner.release();
363
- }
364
-
365
- app = moduleRef.createNestApplication();
366
- await app.init();
367
- });
368
-
369
- afterAll(async () => {
370
- await app?.close();
371
- });
372
-
373
- it('boots with EventModule, a consumer ModuleRef token, and generated CRUD', async () => {
374
- const schema = app.get(GraphQLSchemaHost).schema;
375
- const queryFields = Object.keys(schema.getQueryType()!.getFields());
376
- const mutationFields = Object.keys(schema.getMutationType()!.getFields());
377
- expect(queryFields).toEqual(
378
- expect.arrayContaining([
379
- 'getExampleExtensionApi',
380
- 'getExampleExtensionApiGrid',
381
- 'getExampleLinkApi',
382
- 'getExampleLinkApiGrid',
383
- ]),
384
- );
385
- expect(mutationFields).toEqual(
386
- expect.arrayContaining([
387
- 'createExampleExtensionApi',
388
- 'updateExampleExtensionApi',
389
- 'deleteExampleExtensionApi',
390
- 'createExampleLinkApi',
391
- 'updateExampleLinkApi',
392
- 'deleteExampleLinkApi',
393
- ]),
394
- );
395
-
396
- await request(app.getHttpServer())
397
- .post('/example-extensions')
398
- .send({ guid: restGuid, label: 'REST extension', enabled: true })
399
- .expect(201)
400
- .expect(({ body }) => {
401
- expect(body).toMatchObject({
402
- guid: restGuid,
403
- label: 'REST extension',
404
- enabled: true,
405
- revision: 1,
406
- });
407
- });
408
-
409
- await request(app.getHttpServer())
410
- .get(`/example-extensions/${restGuid}`)
411
- .expect(200)
412
- .expect(({ body }) => {
413
- expect(body.enabled).toBe(true);
414
- });
415
- await request(app.getHttpServer())
416
- .get('/example-extensions')
417
- .expect(200)
418
- .expect(({ body }) => {
419
- expect(body.list).toEqual(
420
- expect.arrayContaining([expect.objectContaining({ guid: restGuid })]),
421
- );
422
- });
423
- await request(app.getHttpServer())
424
- .put(`/example-extensions/${restGuid}`)
425
- .send({ label: 'REST extension patched', enabled: false, expectedRevision: 1 })
426
- .expect(200)
427
- .expect(({ body }) => {
428
- expect(body).toMatchObject({
429
- guid: restGuid,
430
- label: 'REST extension patched',
431
- enabled: false,
432
- revision: 2,
433
- });
434
- });
435
-
436
- const graphCreate = await request(app.getHttpServer())
437
- .post('/graphql')
438
- .send({
439
- query: `mutation CreateExample($input: TransportExampleExtensionApiCreateInput!) {
440
- createExampleExtensionApi(input: $input) { guid label enabled revision }
441
- }`,
442
- variables: {
443
- input: {
444
- guid: graphqlGuid,
445
- label: 'GraphQL extension',
446
- enabled: true,
447
- },
448
- },
449
- })
450
- .expect(200);
451
- expect(graphCreate.body.errors).toBeUndefined();
452
- expect(graphCreate.body.data.createExampleExtensionApi).toMatchObject({
453
- guid: graphqlGuid,
454
- enabled: true,
455
- revision: 1,
456
- });
457
-
458
- await request(app.getHttpServer())
459
- .post('/example-links')
460
- .send({
461
- guid: relationGuid,
462
- linkKind: 'example_link',
463
- sourceWorkItemId: restGuid,
464
- targetWorkItemId: graphqlGuid,
465
- metadata: { origin: 'generated REST' },
466
- })
467
- .expect(201)
468
- .expect(({ body }) => {
469
- expect(body).toMatchObject({
470
- guid: relationGuid,
471
- linkKind: 'example_link',
472
- sourceWorkItemId: restGuid,
473
- targetWorkItemId: graphqlGuid,
474
- metadata: { origin: 'generated REST' },
475
- });
476
- expect(body.kind).toBeUndefined();
477
- expect(body.status).toBeUndefined();
478
- });
479
-
480
- const graphRead = await request(app.getHttpServer())
481
- .post('/graphql')
482
- .send({
483
- query: `query {
484
- getExampleExtensionApi(guid: "${restGuid}") { guid label enabled revision }
485
- getExampleExtensionApiGrid { nodes { guid label enabled } pageData { count } }
486
- }`,
487
- })
488
- .expect(200);
489
- expect(graphRead.body.errors).toBeUndefined();
490
- expect(graphRead.body.data.getExampleExtensionApi).toMatchObject({
491
- guid: restGuid,
492
- enabled: false,
493
- revision: 2,
494
- });
495
- expect(graphRead.body.data.getExampleExtensionApiGrid.nodes).toEqual(
496
- expect.arrayContaining([
497
- expect.objectContaining({ guid: restGuid }),
498
- expect.objectContaining({ guid: graphqlGuid }),
499
- ]),
500
- );
501
-
502
- const graphRelation = await request(app.getHttpServer())
503
- .post('/graphql')
504
- .send({
505
- query: `query {
506
- getExampleLinkApi(guid: "${relationGuid}") {
507
- guid linkKind sourceWorkItemId targetWorkItemId metadata
508
- }
509
- getExampleLinkApiGrid { nodes { guid } pageData { count } }
510
- }`,
511
- })
512
- .expect(200);
513
- expect(graphRelation.body.errors).toBeUndefined();
514
- expect(graphRelation.body.data.getExampleLinkApi).toMatchObject({
515
- guid: relationGuid,
516
- linkKind: 'example_link',
517
- sourceWorkItemId: restGuid,
518
- targetWorkItemId: graphqlGuid,
519
- });
520
- expect(graphRelation.body.data.getExampleLinkApiGrid.nodes).toEqual(
521
- expect.arrayContaining([expect.objectContaining({ guid: relationGuid })]),
522
- );
523
-
524
- const graphRelationUpdate = await request(app.getHttpServer())
525
- .post('/graphql')
526
- .send({
527
- query: `mutation UpdateLink(
528
- $conditions: TransportExampleLinkApiCondition!
529
- $input: TransportExampleLinkApiUpdateInput!
530
- ) {
531
- updateExampleLinkApi(conditions: $conditions, input: $input) {
532
- guid metadata revision
533
- }
534
- }`,
535
- variables: {
536
- conditions: { guid: relationGuid },
537
- input: {
538
- metadata: { origin: 'generated GraphQL' },
539
- expectedRevision: 1,
540
- },
541
- },
542
- })
543
- .expect(200);
544
- expect(graphRelationUpdate.body.errors).toBeUndefined();
545
- expect(graphRelationUpdate.body.data.updateExampleLinkApi).toMatchObject({
546
- guid: relationGuid,
547
- metadata: { origin: 'generated GraphQL' },
548
- });
549
-
550
- const graphUpdate = await request(app.getHttpServer())
551
- .post('/graphql')
552
- .send({
553
- query: `mutation UpdateExample(
554
- $conditions: TransportExampleExtensionApiCondition!
555
- $input: TransportExampleExtensionApiUpdateInput!
556
- ) {
557
- updateExampleExtensionApi(conditions: $conditions, input: $input) {
558
- guid label enabled revision
559
- }
560
- }`,
561
- variables: {
562
- conditions: { guid: graphqlGuid },
563
- input: { label: 'GraphQL extension patched', enabled: false, expectedRevision: 1 },
564
- },
565
- })
566
- .expect(200);
567
- expect(graphUpdate.body.errors).toBeUndefined();
568
- expect(graphUpdate.body.data.updateExampleExtensionApi).toMatchObject({
569
- guid: graphqlGuid,
570
- enabled: false,
571
- revision: 2,
572
- });
573
-
574
- await request(app.getHttpServer())
575
- .post('/raw-records')
576
- .send({
577
- guid: rejectedRecordGuid,
578
- title: 'attempted owner',
579
- kind: definition.owner.kind,
580
- status: OmniRecordStatus.Active,
581
- payloadSchemaId: definition.owner.schema.id,
582
- payloadSchemaVersion: definition.owner.schema.version,
583
- })
584
- .expect(400);
585
- await request(app.getHttpServer())
586
- .post('/raw-records')
587
- .send({
588
- guid: normalRecordGuid,
589
- title: 'normal record',
590
- kind: 'normal_record',
591
- status: OmniRecordStatus.Active,
592
- })
593
- .expect(201);
594
- await request(app.getHttpServer())
595
- .post('/example-links')
596
- .send({
597
- guid: '10000000-0000-4000-8000-000000000207',
598
- linkKind: 'example_link',
599
- sourceWorkItemId: normalRecordGuid,
600
- targetWorkItemId: graphqlGuid,
601
- })
602
- .expect(400);
603
- await request(app.getHttpServer())
604
- .put(`/raw-records/${normalRecordGuid}`)
605
- .send({ kind: definition.owner.kind })
606
- .expect(400);
607
- await request(app.getHttpServer())
608
- .put(`/raw-records/${restGuid}`)
609
- .send({ kind: 'normal_record' })
610
- .expect(400);
611
- await request(app.getHttpServer())
612
- .delete(`/raw-records/${restGuid}`)
613
- .expect(400);
614
-
615
- for (const requestBody of [
616
- {
617
- query: `mutation CreateRaw($input: OmniRecordCreateInput!) {
618
- Raw_createOmniRecordEntity(input: $input) { guid }
619
- }`,
620
- variables: {
621
- input: {
622
- guid: '10000000-0000-4000-8000-000000000205',
623
- title: 'attempted GraphQL owner',
624
- kind: definition.owner.kind,
625
- status: 'Active',
626
- },
627
- },
628
- },
629
- {
630
- query: `mutation UpdateRaw(
631
- $conditions: OmniRecordCondition!
632
- $input: OmniRecordUpdateInput!
633
- ) {
634
- Raw_updateOmniRecordEntity(conditions: $conditions, input: $input) { guid }
635
- }`,
636
- variables: {
637
- conditions: { guid: restGuid },
638
- input: { kind: 'normal_record' },
639
- },
640
- },
641
- {
642
- query: `mutation DeleteRaw($conditions: OmniRecordCondition!) {
643
- Raw_deleteOmniRecordEntity(conditions: $conditions)
644
- }`,
645
- variables: { conditions: { guid: restGuid } },
646
- },
647
- ]) {
648
- const response = await request(app.getHttpServer())
649
- .post('/graphql')
650
- .send(requestBody);
651
- expect([200, 400]).toContain(response.status);
652
- expect(response.body.data ?? null).toBeNull();
653
- expect(response.body.errors?.[0]?.message ?? response.body.message).toContain(
654
- 'owned by a registered extension projection',
655
- );
656
- }
657
-
658
- const graphRelationDelete = await request(app.getHttpServer())
659
- .post('/graphql')
660
- .send({
661
- query: `mutation DeleteLink($conditions: TransportExampleLinkApiCondition!) {
662
- deleteExampleLinkApi(conditions: $conditions)
663
- }`,
664
- variables: { conditions: { guid: relationGuid } },
665
- })
666
- .expect(200);
667
- expect(graphRelationDelete.body.errors).toBeUndefined();
668
- expect(graphRelationDelete.body.data.deleteExampleLinkApi).toBe(true);
669
-
670
- const graphDelete = await request(app.getHttpServer())
671
- .post('/graphql')
672
- .send({
673
- query: `mutation DeleteExample($conditions: TransportExampleExtensionApiCondition!) {
674
- deleteExampleExtensionApi(conditions: $conditions)
675
- }`,
676
- variables: { conditions: { guid: graphqlGuid } },
677
- })
678
- .expect(200);
679
- expect(graphDelete.body.errors).toBeUndefined();
680
- expect(graphDelete.body.data.deleteExampleExtensionApi).toBe(true);
681
-
682
- await request(app.getHttpServer())
683
- .delete(`/example-extensions/${restGuid}`)
684
- .expect(200)
685
- .expect({ deleted: true });
686
- expect(
687
- await dataSource.getRepository(OmniRecordEntity).findOneBy({
688
- scopeId: scope.scopeId,
689
- guid: restGuid,
690
- }),
691
- ).toBeNull();
692
- expect(lifecycleBeforeCreate).toHaveBeenCalledTimes(3);
693
- expect(lifecycleBeforeUpdate).toHaveBeenCalledTimes(3);
694
- expect(lifecycleBeforeDelete).toHaveBeenCalledTimes(3);
695
- });
696
- });
697
-
698
- describe('Omni migration snapshot ordering', () => {
699
- it('orders referenced extension tables independently of snapshot order', () => {
700
- const parent = {
701
- name: 'projection_parent',
702
- columns: [],
703
- };
704
- const child = {
705
- name: 'projection_child',
706
- columns: [],
707
- foreignKeys: [
708
- {
709
- name: 'projection_child_parent_fk',
710
- columnNames: ['scopeId', 'parentGuid'],
711
- referencedTableName: 'projection_parent',
712
- referencedColumnNames: ['scopeId', 'guid'],
713
- onDelete: 'RESTRICT' as const,
714
- },
715
- ],
716
- };
717
- const plan = createOmniMigrationPlan(
718
- defineOmniMigrationSnapshot({
719
- version: 'projection-order-v1',
720
- dialect: 'sqlite',
721
- tables: [child, parent],
722
- indexStatements: [],
723
- }),
724
- );
725
-
726
- expect(plan.tableNames).toEqual(['projection_parent', 'projection_child']);
727
- expect(() =>
728
- createOmniMigrationPlan(
729
- defineOmniMigrationSnapshot({
730
- version: 'projection-order-cycle-v1',
731
- dialect: 'sqlite',
732
- tables: [
733
- child,
734
- {
735
- ...parent,
736
- foreignKeys: [
737
- {
738
- name: 'projection_parent_child_fk',
739
- columnNames: ['scopeId', 'childGuid'],
740
- referencedTableName: 'projection_child',
741
- referencedColumnNames: ['scopeId', 'guid'],
742
- onDelete: 'RESTRICT',
743
- },
744
- ],
745
- },
746
- ],
747
- indexStatements: [],
748
- }),
749
- ),
750
- ).toThrow('cross-table foreign-key cycle');
751
- });
752
- });
753
-
754
- describe.each(dialectCases)(
755
- "Omni migration plan (%s)",
756
- ({ name, url }) => {
757
- let dataSource: DataSource;
758
-
759
- afterEach(async () => {
760
- if (dataSource?.isInitialized) await dataSource.destroy();
761
- });
762
-
763
- it("uses a frozen metadata snapshot to create Omni base and extension tables without synchronize", async () => {
764
- const entity = createOmniExtensionProjectionEntity<ExtensionRow>(
765
- definition,
766
- createProjectionDialect(name),
767
- );
768
- const registration = createOmniExtensionProjectionRegistration({
769
- entity,
770
- apiModel: ExampleExtensionApi,
771
- definition,
772
- dbConnection: "default",
773
- });
774
- dataSource = new DataSource({
775
- type: name,
776
- ...(name === "sqlite"
777
- ? { database: ":memory:" }
778
- : { url: url!, ssl: false }),
779
- synchronize: false,
780
- entities: [
781
- OmniNamedEntity,
782
- OmniRecordEntity,
783
- OmniDocumentEntity,
784
- OmniCollectionEntity,
785
- OmniRelationEntity,
786
- OmniExternalRefEntity,
787
- ...registration.entities,
788
- ],
789
- });
790
- await dataSource.initialize();
791
- if (name === "postgres") await dataSource.dropDatabase();
792
-
793
- const captured = captureOmniMigrationSnapshot(
794
- `example-nest-extension-${name}-v1`,
795
- dataSource,
796
- [registration],
797
- );
798
- const migrationSource = structuredClone(captured);
799
- const plan = createOmniMigrationPlan(
800
- defineOmniMigrationSnapshot(migrationSource),
801
- );
802
- expect(dataSource.options.synchronize).toBe(false);
803
- expect(Object.isFrozen(plan)).toBe(true);
804
- expect(Object.isFrozen(plan.tableNames)).toBe(true);
805
- expect(Object.isFrozen(plan.indexStatements)).toBe(true);
806
- expect(Object.isFrozen(captured)).toBe(true);
807
- expect(Object.isFrozen(captured.tables)).toBe(true);
808
- expect(Object.isFrozen(captured.indexStatements)).toBe(true);
809
- expect(plan.tableNames).toEqual(
810
- expect.arrayContaining([
811
- "omni-named",
812
- "omni-record",
813
- "omni-relation",
814
- "omni-external-ref",
815
- definition.tableName,
816
- ]),
817
- );
818
- const shuffledPlan = createOmniMigrationPlan(
819
- defineOmniMigrationSnapshot({
820
- ...captured,
821
- tables: [...captured.tables].reverse(),
822
- }),
823
- );
824
- expect(
825
- shuffledPlan.tableNames.indexOf('omni-record'),
826
- ).toBeLessThan(shuffledPlan.tableNames.indexOf(definition.tableName));
827
-
828
- const firstSnapshot = plan.createTables();
829
- firstSnapshot[0].name = "mutated-copy";
830
- migrationSource.tables[0].name = "mutated-migration-source";
831
- migrationSource.indexStatements[0] = "mutated-index-statement";
832
- expect(plan.createTables()[0].name).not.toBe("mutated-copy");
833
- expect(plan.createTables()[0].name).not.toBe(
834
- "mutated-migration-source",
835
- );
836
- expect(plan.indexStatements[0]).not.toBe("mutated-index-statement");
837
- const enabledIndex = definition.fields.find(
838
- (field) => field.name === "enabled",
839
- )!.index!.name;
840
- expect(plan.indexStatements).toEqual(
841
- expect.arrayContaining([
842
- expect.stringContaining(`CREATE INDEX "${enabledIndex}"`),
843
- ]),
844
- );
845
-
846
- const queryRunner = dataSource.createQueryRunner();
847
- await queryRunner.connect();
848
- try {
849
- await plan.create(queryRunner);
850
- } finally {
851
- await queryRunner.release();
852
- }
853
-
854
- const verificationRunner = dataSource.createQueryRunner();
855
- await verificationRunner.connect();
856
- try {
857
- expect(await verificationRunner.hasTable(definition.tableName)).toBe(
858
- true,
859
- );
860
- } finally {
861
- await verificationRunner.release();
862
- }
863
- const extensionTable = plan
864
- .createTables()
865
- .find((table) => table.name === definition.tableName)!;
866
- expect(extensionTable.foreignKeys).toEqual([
867
- expect.objectContaining({
868
- columnNames: ["scopeId", "guid"],
869
- referencedTableName: "omni-record",
870
- referencedColumnNames: ["scopeId", "guid"],
871
- onDelete: "CASCADE",
872
- }),
873
- ]);
874
- expect(extensionTable.findColumnByName("payload")?.type).toBe(
875
- name === "postgres" ? "jsonb" : "text",
876
- );
877
-
878
- const serviceProvider = registration.providers.find(
879
- (provider) =>
880
- typeof provider === "object" &&
881
- provider !== null &&
882
- "provide" in provider &&
883
- provider.provide === registration.serviceToken,
884
- ) as any;
885
- const service = serviceProvider.useFactory(dataSource, scope, events);
886
- await service.createEntity({
887
- guid: "10000000-0000-4000-8000-000000000101",
888
- label: "migration-owned",
889
- enabled: true,
890
- });
891
- expect(
892
- await dataSource.getRepository(OmniRecordEntity).countBy({
893
- scopeId: scope.scopeId,
894
- guid: "10000000-0000-4000-8000-000000000101",
895
- }),
896
- ).toBe(1);
897
-
898
- const projectionDialect = createProjectionDialect(name);
899
- const evidence = await projectionDialect.inspect(dataSource, definition);
900
- expect(evidence.indexes).toContain(enabledIndex);
901
- const indexStatement = plan.indexStatements.find((statement) =>
902
- statement.includes(`CREATE INDEX "${enabledIndex}"`),
903
- )!;
904
- const prefix = `CREATE INDEX "${enabledIndex}" ON "${definition.tableName}" ("scopeId", `;
905
- const suffix = `, "${definition.identity.column}")`;
906
- expect(indexStatement.startsWith(prefix)).toBe(true);
907
- expect(indexStatement.endsWith(suffix)).toBe(true);
908
- const expression = indexStatement.slice(prefix.length, -suffix.length);
909
- const planRunner = dataSource.createQueryRunner();
910
- await planRunner.connect();
911
- try {
912
- if (name === "postgres") {
913
- await planRunner.startTransaction();
914
- await planRunner.query("SET LOCAL enable_seqscan = off");
915
- const rows = await planRunner.query(
916
- `EXPLAIN (COSTS OFF) SELECT "${definition.identity.column}" FROM "${definition.tableName}" WHERE "${definition.scope.column}" = $1 AND ${expression} = $2 ORDER BY "${definition.identity.column}" ASC`,
917
- [scope.scopeId, true],
918
- );
919
- expect(JSON.stringify(rows)).toContain(enabledIndex);
920
- } else {
921
- const rows = await planRunner.query(
922
- `EXPLAIN QUERY PLAN SELECT "${definition.identity.column}" FROM "${definition.tableName}" INDEXED BY "${enabledIndex}" WHERE "${definition.scope.column}" = ? AND ${expression} = ? ORDER BY "${definition.identity.column}" ASC`,
923
- [scope.scopeId, 1],
924
- );
925
- expect(JSON.stringify(rows)).toContain(enabledIndex);
926
- }
927
- } finally {
928
- if (planRunner.isTransactionActive) await planRunner.rollbackTransaction();
929
- await planRunner.release();
930
- }
931
-
932
- const downRunner = dataSource.createQueryRunner();
933
- await downRunner.connect();
934
- try {
935
- await plan.drop(downRunner);
936
- expect(await downRunner.hasTable(definition.tableName)).toBe(false);
937
- expect(await downRunner.hasTable('omni-record')).toBe(false);
938
- await plan.create(downRunner);
939
- expect(await downRunner.hasTable(definition.tableName)).toBe(true);
940
- expect(await downRunner.hasTable('omni-record')).toBe(true);
941
- } finally {
942
- await downRunner.release();
943
- }
944
- });
945
- },
946
- );
1
+ import { Scope, type INestApplication } from '@nestjs/common';
2
+ import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals';
3
+ import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
4
+ import { EventEmitterModule } from '@nestjs/event-emitter';
5
+ import { GraphQLModule, GraphQLSchemaHost } from '@nestjs/graphql';
6
+ import { ModuleRef } from '@nestjs/core';
7
+ import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm';
8
+ import { Test } from '@nestjs/testing';
9
+ jest.setTimeout(30000);
10
+ import {
11
+ ConflictException,
12
+ NotFoundException,
13
+ } from '@nestjs/common';
14
+ import {
15
+ createProjectionDialect,
16
+ CrudGenResourceFactory,
17
+ } from "@nest-yalc-2/crud-gen";
18
+ import { EventModule } from '@nest-yalc-2/event-manager';
19
+ import { UUIDScalar } from '@nest-yalc-2/graphql/scalars/uuid.scalar.js';
20
+ import request from 'supertest';
21
+ import { DataSource } from "typeorm";
22
+ import type { OmniMigrationRunner } from '../omni-migration.js';
23
+
24
+ import { OmniNamedEntity } from "../base/omni-named.entity.js";
25
+ import { OmniRecordEntity } from "../base/omni-record.entity.js";
26
+ import { OmniRelationEntity } from "../base/omni-relation.entity.js";
27
+ import { OmniExternalRefEntity } from "../base/omni-external-ref.entity.js";
28
+ import { OmniCollectionEntity } from "../omni-collection.entity.js";
29
+ import { OmniDocumentEntity } from "../omni-document.entity.js";
30
+ import { OmniRecordStatus } from "../omni-record-status.enum.js";
31
+ import { OmniKernelModule } from '../omnikernel.module.js';
32
+ import {
33
+ OmniRecordCondition,
34
+ OmniRecordCreateInput,
35
+ OmniRecordType,
36
+ OmniRecordUpdateInput,
37
+ } from '../omni-record.dto.js';
38
+ import {
39
+ createOmniExtensionProjectionEntity,
40
+ defineOmniExtensionProjection,
41
+ } from "../omni-extension-projection.definition.js";
42
+ import { createOmniExtensionProjectionRegistration } from "../omni-extension-projection.resource.js";
43
+ import { defineOmniRelationProjection } from '../omni-relation-projection.definition.js';
44
+ import { createOmniRelationProjectionRegistration } from '../omni-relation-projection.resource.js';
45
+ import {
46
+ captureOmniMigrationSnapshot,
47
+ createOmniMigrationPlan,
48
+ defineOmniMigrationSnapshot,
49
+ } from "../omni-migration.js";
50
+ import { createOmniProjectionReaderCatalogProvider } from '../omni-projection.catalog.js';
51
+
52
+ type ExtensionRow = {
53
+ scopeId: string;
54
+ guid: string;
55
+ label: string;
56
+ payload: Record<string, unknown>;
57
+ };
58
+
59
+ class ExampleExtensionApi {
60
+ guid!: string;
61
+ label!: string;
62
+ enabled!: boolean;
63
+ revision!: number;
64
+ payload!: Record<string, unknown>;
65
+ }
66
+
67
+ class ExampleLinkApi {
68
+ guid!: string;
69
+ sourceRecordId!: string;
70
+ targetRecordId!: string;
71
+ revision!: number;
72
+ payload!: Record<string, unknown>;
73
+ }
74
+
75
+ const rawRecordResource = CrudGenResourceFactory<typeof OmniRecordEntity>({
76
+ entityModel: OmniRecordEntity,
77
+ backend: false,
78
+ graphql: {
79
+ resolver: {
80
+ dto: OmniRecordType,
81
+ input: {
82
+ create: OmniRecordCreateInput,
83
+ update: OmniRecordUpdateInput,
84
+ conditions: OmniRecordCondition,
85
+ },
86
+ prefix: 'Raw_',
87
+ },
88
+ },
89
+ rest: {
90
+ dto: OmniRecordType,
91
+ path: 'raw-records',
92
+ idField: 'guid',
93
+ },
94
+ });
95
+
96
+ const definition = defineOmniExtensionProjection({
97
+ id: "example.nest-extension.v1",
98
+ tableName: "example-nest-extension",
99
+ identity: { column: "guid", uniqueWithinScope: true },
100
+ scope: { column: "scopeId", serverOwned: true },
101
+ revision: { column: "revision" },
102
+ payload: { column: "payload", allowCreate: true },
103
+ deletion: "hard",
104
+ fields: [
105
+ {
106
+ name: "guid",
107
+ storage: "column",
108
+ column: "guid",
109
+ codec: "uuid",
110
+ nullable: false,
111
+ requiredOnCreate: true,
112
+ },
113
+ {
114
+ name: "label",
115
+ storage: "column",
116
+ column: "label",
117
+ codec: "string",
118
+ nullable: false,
119
+ requiredOnCreate: true,
120
+ query: { filter: ["eq"], sort: true },
121
+ },
122
+ {
123
+ name: "enabled",
124
+ storage: "json",
125
+ path: ["state", "enabled"],
126
+ codec: "boolean",
127
+ nullable: false,
128
+ requiredOnCreate: true,
129
+ index: { name: "example_nest_extension_enabled_idx" },
130
+ query: { filter: ["eq"], sort: true },
131
+ },
132
+ ],
133
+ owner: {
134
+ kind: "example_nest_extension",
135
+ title: "Example Nest extension",
136
+ status: OmniRecordStatus.Active,
137
+ schema: { id: "example.nest-extension.v1", version: 1 },
138
+ },
139
+ });
140
+
141
+ const relationDefinition = defineOmniRelationProjection({
142
+ id: 'example.nest-extension-link.v1',
143
+ relation: {
144
+ allowedKinds: ['example_link', 'example_blocks'],
145
+ sourceKind: definition.owner.kind,
146
+ targetKind: definition.owner.kind,
147
+ schema: { id: 'example.nest-extension-link.v1', version: 1 },
148
+ },
149
+ aliases: {
150
+ kind: 'linkKind',
151
+ source: 'sourceWorkItemId',
152
+ target: 'targetWorkItemId',
153
+ payload: 'metadata',
154
+ },
155
+ });
156
+
157
+ const scope = {
158
+ scopeId: "scope-registration",
159
+ cacheKey: (key: string) => `scope-registration:${key}`,
160
+ };
161
+
162
+ const events = {
163
+ errorBadRequest: (_event: string, options: any) =>
164
+ new ConflictException(options.response.message),
165
+ errorConflict: (_event: string, options: any) =>
166
+ new ConflictException(options.response.message),
167
+ errorNotFound: (_event: string, options: any) =>
168
+ new NotFoundException(options.response.message),
169
+ };
170
+
171
+ const lifecycleToken = Symbol('example-projection-lifecycle');
172
+ const catalogToken = Symbol('example-projection-catalog');
173
+ const moduleRefToken = Symbol('example-consumer-module-ref');
174
+ const lifecycleBeforeCreate = jest.fn();
175
+ const lifecycleBeforeUpdate = jest.fn();
176
+ const lifecycleBeforeDelete = jest.fn();
177
+
178
+ const postgresUrl = process.env.OMNI_EXTENSION_PROJECTION_POSTGRES_URL;
179
+ const dialectCases: Array<{ name: "sqlite" | "postgres"; url?: string }> = [
180
+ { name: "sqlite" },
181
+ ...(postgresUrl ? [{ name: "postgres" as const, url: postgresUrl }] : []),
182
+ ];
183
+
184
+ describe("Omni extension projection registration", () => {
185
+ it("returns generated CrudGen REST and GraphQL surfaces with request-scoped providers", () => {
186
+ const entity = createOmniExtensionProjectionEntity<ExtensionRow>(
187
+ definition,
188
+ createProjectionDialect("sqlite"),
189
+ );
190
+ const registration = createOmniExtensionProjectionRegistration({
191
+ entity,
192
+ apiModel: ExampleExtensionApi,
193
+ definition,
194
+ dbConnection: "default",
195
+ rest: { path: "example-extensions" },
196
+ });
197
+
198
+ expect(Object.isFrozen(registration)).toBe(true);
199
+ expect(registration.definition).toBe(definition);
200
+ expect(registration.entities).toEqual([entity]);
201
+ expect(registration.reservedRecordKinds).toEqual([definition.owner.kind]);
202
+ expect(registration.controllers).toHaveLength(1);
203
+ expect(registration.resource.providers).toHaveLength(1);
204
+ expect(registration.providers).toHaveLength(3);
205
+ expect(
206
+ registration.providers.filter(
207
+ (provider) =>
208
+ typeof provider === 'object' &&
209
+ provider !== null &&
210
+ 'provide' in provider &&
211
+ provider.provide === registration.serviceToken,
212
+ ),
213
+ ).toHaveLength(1);
214
+ expect(
215
+ registration.providers.filter(
216
+ (provider) =>
217
+ typeof provider === 'object' &&
218
+ provider !== null &&
219
+ 'provide' in provider &&
220
+ provider.provide === registration.dataLoaderToken,
221
+ ),
222
+ ).toHaveLength(1);
223
+
224
+ const relationRegistration = createOmniRelationProjectionRegistration({
225
+ apiModel: ExampleLinkApi,
226
+ definition: relationDefinition,
227
+ dbConnection: 'default',
228
+ rest: { path: 'example-links' },
229
+ });
230
+ expect(Object.isFrozen(relationRegistration)).toBe(true);
231
+ expect(relationRegistration.entities).toEqual([]);
232
+ expect(relationRegistration.relationKinds).toEqual([
233
+ 'example_link',
234
+ 'example_blocks',
235
+ ]);
236
+ expect(relationRegistration.controllers).toHaveLength(1);
237
+ expect(relationRegistration.providers).toHaveLength(3);
238
+ });
239
+ });
240
+
241
+ describe('Omni extension projection generated transport', () => {
242
+ let app: INestApplication;
243
+ let dataSource: DataSource;
244
+ const restGuid = '10000000-0000-4000-8000-000000000201';
245
+ const graphqlGuid = '10000000-0000-4000-8000-000000000202';
246
+ const relationGuid = '10000000-0000-4000-8000-000000000206';
247
+ const normalRecordGuid = '10000000-0000-4000-8000-000000000203';
248
+ const rejectedRecordGuid = '10000000-0000-4000-8000-000000000204';
249
+
250
+ beforeAll(async () => {
251
+ const entity = createOmniExtensionProjectionEntity<ExtensionRow>(
252
+ definition,
253
+ createProjectionDialect('sqlite'),
254
+ );
255
+ const registration = createOmniExtensionProjectionRegistration({
256
+ entity,
257
+ apiModel: ExampleExtensionApi,
258
+ definition,
259
+ dbConnection: 'default',
260
+ catalog: { token: catalogToken },
261
+ lifecycle: { token: lifecycleToken },
262
+ moduleRefToken,
263
+ rest: { path: 'example-extensions' },
264
+ graphql: {
265
+ names: {
266
+ object: 'TransportExampleExtensionApi',
267
+ create: 'TransportExampleExtensionApiCreateInput',
268
+ patch: 'TransportExampleExtensionApiUpdateInput',
269
+ conditions: 'TransportExampleExtensionApiCondition',
270
+ },
271
+ },
272
+ });
273
+ const relationRegistration = createOmniRelationProjectionRegistration({
274
+ apiModel: ExampleLinkApi,
275
+ definition: relationDefinition,
276
+ dbConnection: 'default',
277
+ catalog: { token: catalogToken },
278
+ lifecycle: { token: lifecycleToken },
279
+ moduleRefToken,
280
+ rest: { path: 'example-links' },
281
+ graphql: {
282
+ names: {
283
+ object: 'TransportExampleLinkApi',
284
+ create: 'TransportExampleLinkApiCreateInput',
285
+ patch: 'TransportExampleLinkApiUpdateInput',
286
+ conditions: 'TransportExampleLinkApiCondition',
287
+ },
288
+ },
289
+ });
290
+ const moduleRef = await Test.createTestingModule({
291
+ imports: [
292
+ GraphQLModule.forRoot<ApolloDriverConfig>({
293
+ driver: ApolloDriver,
294
+ autoSchemaFile: true,
295
+ path: '/graphql',
296
+ }),
297
+ EventEmitterModule.forRoot(),
298
+ EventModule.forRootAsync(),
299
+ TypeOrmModule.forRoot({
300
+ type: 'sqlite',
301
+ database: ':memory:',
302
+ synchronize: false,
303
+ autoLoadEntities: true,
304
+ }),
305
+ OmniKernelModule.register({
306
+ dbConnection: 'default',
307
+ defaultScopeId: scope.scopeId,
308
+ reservedRecordKinds: registration.reservedRecordKinds,
309
+ relationKinds: relationRegistration.relationKinds,
310
+ }),
311
+ TypeOrmModule.forFeature(registration.entities),
312
+ ],
313
+ controllers: [
314
+ ...registration.controllers,
315
+ ...relationRegistration.controllers,
316
+ ...rawRecordResource.controllers,
317
+ ],
318
+ providers: [
319
+ UUIDScalar,
320
+ { provide: moduleRefToken, useExisting: ModuleRef },
321
+ createOmniProjectionReaderCatalogProvider(
322
+ [registration, relationRegistration],
323
+ catalogToken,
324
+ ),
325
+ {
326
+ provide: lifecycleToken,
327
+ scope: Scope.REQUEST,
328
+ useFactory: () => ({
329
+ beforeCreate: async (context: any) => {
330
+ lifecycleBeforeCreate(context);
331
+ await context.readers.extension(definition.id).list({
332
+ where: { label: '__missing__' },
333
+ take: 1,
334
+ });
335
+ },
336
+ beforeUpdate: async (context: any) => {
337
+ lifecycleBeforeUpdate(context);
338
+ },
339
+ beforeDelete: async (context: any) => {
340
+ lifecycleBeforeDelete(context);
341
+ },
342
+ }),
343
+ },
344
+ ...registration.providers,
345
+ ...relationRegistration.providers,
346
+ ...rawRecordResource.providers,
347
+ ],
348
+ }).compile();
349
+
350
+ dataSource = moduleRef.get<DataSource>(getDataSourceToken());
351
+ const snapshot = captureOmniMigrationSnapshot(
352
+ 'example-nest-extension-transport-v1',
353
+ dataSource,
354
+ [registration],
355
+ );
356
+ const migration = createOmniMigrationPlan(snapshot);
357
+ const queryRunner = dataSource.createQueryRunner();
358
+ const migrationRunner: OmniMigrationRunner = queryRunner;
359
+ await queryRunner.connect();
360
+ try {
361
+ await migration.create(migrationRunner);
362
+ } finally {
363
+ await queryRunner.release();
364
+ }
365
+
366
+ app = moduleRef.createNestApplication();
367
+ await app.init();
368
+ });
369
+
370
+ afterAll(async () => {
371
+ await app?.close();
372
+ });
373
+
374
+ it('boots with EventModule, a consumer ModuleRef token, and generated CRUD', async () => {
375
+ const schema = app.get(GraphQLSchemaHost).schema;
376
+ const queryFields = Object.keys(schema.getQueryType()!.getFields());
377
+ const mutationFields = Object.keys(schema.getMutationType()!.getFields());
378
+ expect(queryFields).toEqual(
379
+ expect.arrayContaining([
380
+ 'getExampleExtensionApi',
381
+ 'getExampleExtensionApiGrid',
382
+ 'getExampleLinkApi',
383
+ 'getExampleLinkApiGrid',
384
+ ]),
385
+ );
386
+ expect(mutationFields).toEqual(
387
+ expect.arrayContaining([
388
+ 'createExampleExtensionApi',
389
+ 'updateExampleExtensionApi',
390
+ 'deleteExampleExtensionApi',
391
+ 'createExampleLinkApi',
392
+ 'updateExampleLinkApi',
393
+ 'deleteExampleLinkApi',
394
+ ]),
395
+ );
396
+
397
+ await request(app.getHttpServer())
398
+ .post('/example-extensions')
399
+ .send({ guid: restGuid, label: 'REST extension', enabled: true })
400
+ .expect(201)
401
+ .expect(({ body }) => {
402
+ expect(body).toMatchObject({
403
+ guid: restGuid,
404
+ label: 'REST extension',
405
+ enabled: true,
406
+ revision: 1,
407
+ });
408
+ });
409
+
410
+ await request(app.getHttpServer())
411
+ .get(`/example-extensions/${restGuid}`)
412
+ .expect(200)
413
+ .expect(({ body }) => {
414
+ expect(body.enabled).toBe(true);
415
+ });
416
+ await request(app.getHttpServer())
417
+ .get('/example-extensions')
418
+ .expect(200)
419
+ .expect(({ body }) => {
420
+ expect(body.list).toEqual(
421
+ expect.arrayContaining([expect.objectContaining({ guid: restGuid })]),
422
+ );
423
+ });
424
+ await request(app.getHttpServer())
425
+ .put(`/example-extensions/${restGuid}`)
426
+ .send({ label: 'REST extension patched', enabled: false, expectedRevision: 1 })
427
+ .expect(200)
428
+ .expect(({ body }) => {
429
+ expect(body).toMatchObject({
430
+ guid: restGuid,
431
+ label: 'REST extension patched',
432
+ enabled: false,
433
+ revision: 2,
434
+ });
435
+ });
436
+
437
+ const graphCreate = await request(app.getHttpServer())
438
+ .post('/graphql')
439
+ .send({
440
+ query: `mutation CreateExample($input: TransportExampleExtensionApiCreateInput!) {
441
+ createExampleExtensionApi(input: $input) { guid label enabled revision }
442
+ }`,
443
+ variables: {
444
+ input: {
445
+ guid: graphqlGuid,
446
+ label: 'GraphQL extension',
447
+ enabled: true,
448
+ },
449
+ },
450
+ })
451
+ .expect(200);
452
+ expect(graphCreate.body.errors).toBeUndefined();
453
+ expect(graphCreate.body.data.createExampleExtensionApi).toMatchObject({
454
+ guid: graphqlGuid,
455
+ enabled: true,
456
+ revision: 1,
457
+ });
458
+
459
+ await request(app.getHttpServer())
460
+ .post('/example-links')
461
+ .send({
462
+ guid: relationGuid,
463
+ linkKind: 'example_link',
464
+ sourceWorkItemId: restGuid,
465
+ targetWorkItemId: graphqlGuid,
466
+ metadata: { origin: 'generated REST' },
467
+ })
468
+ .expect(201)
469
+ .expect(({ body }) => {
470
+ expect(body).toMatchObject({
471
+ guid: relationGuid,
472
+ linkKind: 'example_link',
473
+ sourceWorkItemId: restGuid,
474
+ targetWorkItemId: graphqlGuid,
475
+ metadata: { origin: 'generated REST' },
476
+ });
477
+ expect(body.kind).toBeUndefined();
478
+ expect(body.status).toBeUndefined();
479
+ });
480
+
481
+ const graphRead = await request(app.getHttpServer())
482
+ .post('/graphql')
483
+ .send({
484
+ query: `query {
485
+ getExampleExtensionApi(guid: "${restGuid}") { guid label enabled revision }
486
+ getExampleExtensionApiGrid { nodes { guid label enabled } pageData { count } }
487
+ }`,
488
+ })
489
+ .expect(200);
490
+ expect(graphRead.body.errors).toBeUndefined();
491
+ expect(graphRead.body.data.getExampleExtensionApi).toMatchObject({
492
+ guid: restGuid,
493
+ enabled: false,
494
+ revision: 2,
495
+ });
496
+ expect(graphRead.body.data.getExampleExtensionApiGrid.nodes).toEqual(
497
+ expect.arrayContaining([
498
+ expect.objectContaining({ guid: restGuid }),
499
+ expect.objectContaining({ guid: graphqlGuid }),
500
+ ]),
501
+ );
502
+
503
+ const graphRelation = await request(app.getHttpServer())
504
+ .post('/graphql')
505
+ .send({
506
+ query: `query {
507
+ getExampleLinkApi(guid: "${relationGuid}") {
508
+ guid linkKind sourceWorkItemId targetWorkItemId metadata
509
+ }
510
+ getExampleLinkApiGrid { nodes { guid } pageData { count } }
511
+ }`,
512
+ })
513
+ .expect(200);
514
+ expect(graphRelation.body.errors).toBeUndefined();
515
+ expect(graphRelation.body.data.getExampleLinkApi).toMatchObject({
516
+ guid: relationGuid,
517
+ linkKind: 'example_link',
518
+ sourceWorkItemId: restGuid,
519
+ targetWorkItemId: graphqlGuid,
520
+ });
521
+ expect(graphRelation.body.data.getExampleLinkApiGrid.nodes).toEqual(
522
+ expect.arrayContaining([expect.objectContaining({ guid: relationGuid })]),
523
+ );
524
+
525
+ const graphRelationUpdate = await request(app.getHttpServer())
526
+ .post('/graphql')
527
+ .send({
528
+ query: `mutation UpdateLink(
529
+ $conditions: TransportExampleLinkApiCondition!
530
+ $input: TransportExampleLinkApiUpdateInput!
531
+ ) {
532
+ updateExampleLinkApi(conditions: $conditions, input: $input) {
533
+ guid metadata revision
534
+ }
535
+ }`,
536
+ variables: {
537
+ conditions: { guid: relationGuid },
538
+ input: {
539
+ metadata: { origin: 'generated GraphQL' },
540
+ expectedRevision: 1,
541
+ },
542
+ },
543
+ })
544
+ .expect(200);
545
+ expect(graphRelationUpdate.body.errors).toBeUndefined();
546
+ expect(graphRelationUpdate.body.data.updateExampleLinkApi).toMatchObject({
547
+ guid: relationGuid,
548
+ metadata: { origin: 'generated GraphQL' },
549
+ });
550
+
551
+ const graphUpdate = await request(app.getHttpServer())
552
+ .post('/graphql')
553
+ .send({
554
+ query: `mutation UpdateExample(
555
+ $conditions: TransportExampleExtensionApiCondition!
556
+ $input: TransportExampleExtensionApiUpdateInput!
557
+ ) {
558
+ updateExampleExtensionApi(conditions: $conditions, input: $input) {
559
+ guid label enabled revision
560
+ }
561
+ }`,
562
+ variables: {
563
+ conditions: { guid: graphqlGuid },
564
+ input: { label: 'GraphQL extension patched', enabled: false, expectedRevision: 1 },
565
+ },
566
+ })
567
+ .expect(200);
568
+ expect(graphUpdate.body.errors).toBeUndefined();
569
+ expect(graphUpdate.body.data.updateExampleExtensionApi).toMatchObject({
570
+ guid: graphqlGuid,
571
+ enabled: false,
572
+ revision: 2,
573
+ });
574
+
575
+ await request(app.getHttpServer())
576
+ .post('/raw-records')
577
+ .send({
578
+ guid: rejectedRecordGuid,
579
+ title: 'attempted owner',
580
+ kind: definition.owner.kind,
581
+ status: OmniRecordStatus.Active,
582
+ payloadSchemaId: definition.owner.schema.id,
583
+ payloadSchemaVersion: definition.owner.schema.version,
584
+ })
585
+ .expect(400);
586
+ await request(app.getHttpServer())
587
+ .post('/raw-records')
588
+ .send({
589
+ guid: normalRecordGuid,
590
+ title: 'normal record',
591
+ kind: 'normal_record',
592
+ status: OmniRecordStatus.Active,
593
+ })
594
+ .expect(201);
595
+ await request(app.getHttpServer())
596
+ .post('/example-links')
597
+ .send({
598
+ guid: '10000000-0000-4000-8000-000000000207',
599
+ linkKind: 'example_link',
600
+ sourceWorkItemId: normalRecordGuid,
601
+ targetWorkItemId: graphqlGuid,
602
+ })
603
+ .expect(400);
604
+ await request(app.getHttpServer())
605
+ .put(`/raw-records/${normalRecordGuid}`)
606
+ .send({ kind: definition.owner.kind })
607
+ .expect(400);
608
+ await request(app.getHttpServer())
609
+ .put(`/raw-records/${restGuid}`)
610
+ .send({ kind: 'normal_record' })
611
+ .expect(400);
612
+ await request(app.getHttpServer())
613
+ .delete(`/raw-records/${restGuid}`)
614
+ .expect(400);
615
+
616
+ for (const requestBody of [
617
+ {
618
+ query: `mutation CreateRaw($input: OmniRecordCreateInput!) {
619
+ Raw_createOmniRecordEntity(input: $input) { guid }
620
+ }`,
621
+ variables: {
622
+ input: {
623
+ guid: '10000000-0000-4000-8000-000000000205',
624
+ title: 'attempted GraphQL owner',
625
+ kind: definition.owner.kind,
626
+ status: 'Active',
627
+ },
628
+ },
629
+ },
630
+ {
631
+ query: `mutation UpdateRaw(
632
+ $conditions: OmniRecordCondition!
633
+ $input: OmniRecordUpdateInput!
634
+ ) {
635
+ Raw_updateOmniRecordEntity(conditions: $conditions, input: $input) { guid }
636
+ }`,
637
+ variables: {
638
+ conditions: { guid: restGuid },
639
+ input: { kind: 'normal_record' },
640
+ },
641
+ },
642
+ {
643
+ query: `mutation DeleteRaw($conditions: OmniRecordCondition!) {
644
+ Raw_deleteOmniRecordEntity(conditions: $conditions)
645
+ }`,
646
+ variables: { conditions: { guid: restGuid } },
647
+ },
648
+ ]) {
649
+ const response = await request(app.getHttpServer())
650
+ .post('/graphql')
651
+ .send(requestBody);
652
+ expect([200, 400]).toContain(response.status);
653
+ expect(response.body.data ?? null).toBeNull();
654
+ expect(response.body.errors?.[0]?.message ?? response.body.message).toContain(
655
+ 'owned by a registered extension projection',
656
+ );
657
+ }
658
+
659
+ const graphRelationDelete = await request(app.getHttpServer())
660
+ .post('/graphql')
661
+ .send({
662
+ query: `mutation DeleteLink($conditions: TransportExampleLinkApiCondition!) {
663
+ deleteExampleLinkApi(conditions: $conditions)
664
+ }`,
665
+ variables: { conditions: { guid: relationGuid } },
666
+ })
667
+ .expect(200);
668
+ expect(graphRelationDelete.body.errors).toBeUndefined();
669
+ expect(graphRelationDelete.body.data.deleteExampleLinkApi).toBe(true);
670
+
671
+ const graphDelete = await request(app.getHttpServer())
672
+ .post('/graphql')
673
+ .send({
674
+ query: `mutation DeleteExample($conditions: TransportExampleExtensionApiCondition!) {
675
+ deleteExampleExtensionApi(conditions: $conditions)
676
+ }`,
677
+ variables: { conditions: { guid: graphqlGuid } },
678
+ })
679
+ .expect(200);
680
+ expect(graphDelete.body.errors).toBeUndefined();
681
+ expect(graphDelete.body.data.deleteExampleExtensionApi).toBe(true);
682
+
683
+ await request(app.getHttpServer())
684
+ .delete(`/example-extensions/${restGuid}`)
685
+ .expect(200)
686
+ .expect({ deleted: true });
687
+ expect(
688
+ await dataSource.getRepository(OmniRecordEntity).findOneBy({
689
+ scopeId: scope.scopeId,
690
+ guid: restGuid,
691
+ }),
692
+ ).toBeNull();
693
+ expect(lifecycleBeforeCreate).toHaveBeenCalledTimes(3);
694
+ expect(lifecycleBeforeUpdate).toHaveBeenCalledTimes(3);
695
+ expect(lifecycleBeforeDelete).toHaveBeenCalledTimes(3);
696
+ });
697
+ });
698
+
699
+ describe('Omni migration snapshot ordering', () => {
700
+ it('orders referenced extension tables independently of snapshot order', () => {
701
+ const parent = {
702
+ name: 'projection_parent',
703
+ columns: [],
704
+ };
705
+ const child = {
706
+ name: 'projection_child',
707
+ columns: [],
708
+ foreignKeys: [
709
+ {
710
+ name: 'projection_child_parent_fk',
711
+ columnNames: ['scopeId', 'parentGuid'],
712
+ referencedTableName: 'projection_parent',
713
+ referencedColumnNames: ['scopeId', 'guid'],
714
+ onDelete: 'RESTRICT' as const,
715
+ },
716
+ ],
717
+ };
718
+ const plan = createOmniMigrationPlan(
719
+ defineOmniMigrationSnapshot({
720
+ version: 'projection-order-v1',
721
+ dialect: 'sqlite',
722
+ tables: [child, parent],
723
+ indexStatements: [],
724
+ }),
725
+ );
726
+
727
+ expect(plan.tableNames).toEqual(['projection_parent', 'projection_child']);
728
+ expect(() =>
729
+ createOmniMigrationPlan(
730
+ defineOmniMigrationSnapshot({
731
+ version: 'projection-order-cycle-v1',
732
+ dialect: 'sqlite',
733
+ tables: [
734
+ child,
735
+ {
736
+ ...parent,
737
+ foreignKeys: [
738
+ {
739
+ name: 'projection_parent_child_fk',
740
+ columnNames: ['scopeId', 'childGuid'],
741
+ referencedTableName: 'projection_child',
742
+ referencedColumnNames: ['scopeId', 'guid'],
743
+ onDelete: 'RESTRICT',
744
+ },
745
+ ],
746
+ },
747
+ ],
748
+ indexStatements: [],
749
+ }),
750
+ ),
751
+ ).toThrow('cross-table foreign-key cycle');
752
+ });
753
+ });
754
+
755
+ describe.each(dialectCases)(
756
+ "Omni migration plan (%s)",
757
+ ({ name, url }) => {
758
+ let dataSource: DataSource;
759
+
760
+ afterEach(async () => {
761
+ if (dataSource?.isInitialized) await dataSource.destroy();
762
+ });
763
+
764
+ it("uses a frozen metadata snapshot to create Omni base and extension tables without synchronize", async () => {
765
+ const entity = createOmniExtensionProjectionEntity<ExtensionRow>(
766
+ definition,
767
+ createProjectionDialect(name),
768
+ );
769
+ const registration = createOmniExtensionProjectionRegistration({
770
+ entity,
771
+ apiModel: ExampleExtensionApi,
772
+ definition,
773
+ dbConnection: "default",
774
+ });
775
+ dataSource = new DataSource({
776
+ type: name,
777
+ ...(name === "sqlite"
778
+ ? { database: ":memory:" }
779
+ : { url: url!, ssl: false }),
780
+ synchronize: false,
781
+ entities: [
782
+ OmniNamedEntity,
783
+ OmniRecordEntity,
784
+ OmniDocumentEntity,
785
+ OmniCollectionEntity,
786
+ OmniRelationEntity,
787
+ OmniExternalRefEntity,
788
+ ...registration.entities,
789
+ ],
790
+ });
791
+ await dataSource.initialize();
792
+ if (name === "postgres") await dataSource.dropDatabase();
793
+
794
+ const captured = captureOmniMigrationSnapshot(
795
+ `example-nest-extension-${name}-v1`,
796
+ dataSource,
797
+ [registration],
798
+ );
799
+ const migrationSource = structuredClone(captured);
800
+ const plan = createOmniMigrationPlan(
801
+ defineOmniMigrationSnapshot(migrationSource),
802
+ );
803
+ expect(dataSource.options.synchronize).toBe(false);
804
+ expect(Object.isFrozen(plan)).toBe(true);
805
+ expect(Object.isFrozen(plan.tableNames)).toBe(true);
806
+ expect(Object.isFrozen(plan.indexStatements)).toBe(true);
807
+ expect(Object.isFrozen(captured)).toBe(true);
808
+ expect(Object.isFrozen(captured.tables)).toBe(true);
809
+ expect(Object.isFrozen(captured.indexStatements)).toBe(true);
810
+ expect(plan.tableNames).toEqual(
811
+ expect.arrayContaining([
812
+ "omni-named",
813
+ "omni-record",
814
+ "omni-relation",
815
+ "omni-external-ref",
816
+ definition.tableName,
817
+ ]),
818
+ );
819
+ const shuffledPlan = createOmniMigrationPlan(
820
+ defineOmniMigrationSnapshot({
821
+ ...captured,
822
+ tables: [...captured.tables].reverse(),
823
+ }),
824
+ );
825
+ expect(
826
+ shuffledPlan.tableNames.indexOf('omni-record'),
827
+ ).toBeLessThan(shuffledPlan.tableNames.indexOf(definition.tableName));
828
+
829
+ const firstSnapshot = plan.createTables();
830
+ firstSnapshot[0].name = "mutated-copy";
831
+ migrationSource.tables[0].name = "mutated-migration-source";
832
+ migrationSource.indexStatements[0] = "mutated-index-statement";
833
+ expect(plan.createTables()[0].name).not.toBe("mutated-copy");
834
+ expect(plan.createTables()[0].name).not.toBe(
835
+ "mutated-migration-source",
836
+ );
837
+ expect(plan.indexStatements[0]).not.toBe("mutated-index-statement");
838
+ const enabledIndex = definition.fields.find(
839
+ (field) => field.name === "enabled",
840
+ )!.index!.name;
841
+ expect(plan.indexStatements).toEqual(
842
+ expect.arrayContaining([
843
+ expect.stringContaining(`CREATE INDEX "${enabledIndex}"`),
844
+ ]),
845
+ );
846
+
847
+ const queryRunner = dataSource.createQueryRunner();
848
+ await queryRunner.connect();
849
+ try {
850
+ await plan.create(queryRunner);
851
+ } finally {
852
+ await queryRunner.release();
853
+ }
854
+
855
+ const verificationRunner = dataSource.createQueryRunner();
856
+ await verificationRunner.connect();
857
+ try {
858
+ expect(await verificationRunner.hasTable(definition.tableName)).toBe(
859
+ true,
860
+ );
861
+ } finally {
862
+ await verificationRunner.release();
863
+ }
864
+ const extensionTable = plan
865
+ .createTables()
866
+ .find((table) => table.name === definition.tableName)!;
867
+ expect(extensionTable.foreignKeys).toEqual([
868
+ expect.objectContaining({
869
+ columnNames: ["scopeId", "guid"],
870
+ referencedTableName: "omni-record",
871
+ referencedColumnNames: ["scopeId", "guid"],
872
+ onDelete: "CASCADE",
873
+ }),
874
+ ]);
875
+ expect(extensionTable.findColumnByName("payload")?.type).toBe(
876
+ name === "postgres" ? "jsonb" : "text",
877
+ );
878
+
879
+ const serviceProvider = registration.providers.find(
880
+ (provider) =>
881
+ typeof provider === "object" &&
882
+ provider !== null &&
883
+ "provide" in provider &&
884
+ provider.provide === registration.serviceToken,
885
+ ) as any;
886
+ const service = serviceProvider.useFactory(dataSource, scope, events);
887
+ await service.createEntity({
888
+ guid: "10000000-0000-4000-8000-000000000101",
889
+ label: "migration-owned",
890
+ enabled: true,
891
+ });
892
+ expect(
893
+ await dataSource.getRepository(OmniRecordEntity).countBy({
894
+ scopeId: scope.scopeId,
895
+ guid: "10000000-0000-4000-8000-000000000101",
896
+ }),
897
+ ).toBe(1);
898
+
899
+ const projectionDialect = createProjectionDialect(name);
900
+ const evidence = await projectionDialect.inspect(dataSource, definition);
901
+ expect(evidence.indexes).toContain(enabledIndex);
902
+ const indexStatement = plan.indexStatements.find((statement) =>
903
+ statement.includes(`CREATE INDEX "${enabledIndex}"`),
904
+ )!;
905
+ const prefix = `CREATE INDEX "${enabledIndex}" ON "${definition.tableName}" ("scopeId", `;
906
+ const suffix = `, "${definition.identity.column}")`;
907
+ expect(indexStatement.startsWith(prefix)).toBe(true);
908
+ expect(indexStatement.endsWith(suffix)).toBe(true);
909
+ const expression = indexStatement.slice(prefix.length, -suffix.length);
910
+ const planRunner = dataSource.createQueryRunner();
911
+ await planRunner.connect();
912
+ try {
913
+ if (name === "postgres") {
914
+ await planRunner.startTransaction();
915
+ await planRunner.query("SET LOCAL enable_seqscan = off");
916
+ const rows = await planRunner.query(
917
+ `EXPLAIN (COSTS OFF) SELECT "${definition.identity.column}" FROM "${definition.tableName}" WHERE "${definition.scope.column}" = $1 AND ${expression} = $2 ORDER BY "${definition.identity.column}" ASC`,
918
+ [scope.scopeId, true],
919
+ );
920
+ expect(JSON.stringify(rows)).toContain(enabledIndex);
921
+ } else {
922
+ const rows = await planRunner.query(
923
+ `EXPLAIN QUERY PLAN SELECT "${definition.identity.column}" FROM "${definition.tableName}" INDEXED BY "${enabledIndex}" WHERE "${definition.scope.column}" = ? AND ${expression} = ? ORDER BY "${definition.identity.column}" ASC`,
924
+ [scope.scopeId, 1],
925
+ );
926
+ expect(JSON.stringify(rows)).toContain(enabledIndex);
927
+ }
928
+ } finally {
929
+ if (planRunner.isTransactionActive) await planRunner.rollbackTransaction();
930
+ await planRunner.release();
931
+ }
932
+
933
+ const downRunner = dataSource.createQueryRunner();
934
+ await downRunner.connect();
935
+ try {
936
+ await plan.drop(downRunner);
937
+ expect(await downRunner.hasTable(definition.tableName)).toBe(false);
938
+ expect(await downRunner.hasTable('omni-record')).toBe(false);
939
+ await plan.create(downRunner);
940
+ expect(await downRunner.hasTable(definition.tableName)).toBe(true);
941
+ expect(await downRunner.hasTable('omni-record')).toBe(true);
942
+ } finally {
943
+ await downRunner.release();
944
+ }
945
+ });
946
+ },
947
+ );