@nocobase/flow-engine 2.1.0-alpha.10 → 2.1.0-alpha.11

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 (46) hide show
  1. package/lib/FlowDefinition.d.ts +4 -0
  2. package/lib/FlowSchemaRegistry.d.ts +154 -0
  3. package/lib/FlowSchemaRegistry.js +1427 -0
  4. package/lib/components/subModel/AddSubModelButton.js +11 -0
  5. package/lib/flow-schema-registry/fieldBinding.d.ts +32 -0
  6. package/lib/flow-schema-registry/fieldBinding.js +165 -0
  7. package/lib/flow-schema-registry/modelPatches.d.ts +16 -0
  8. package/lib/flow-schema-registry/modelPatches.js +235 -0
  9. package/lib/flow-schema-registry/schemaInference.d.ts +17 -0
  10. package/lib/flow-schema-registry/schemaInference.js +207 -0
  11. package/lib/flow-schema-registry/utils.d.ts +25 -0
  12. package/lib/flow-schema-registry/utils.js +293 -0
  13. package/lib/flowEngine.js +4 -1
  14. package/lib/index.d.ts +1 -0
  15. package/lib/index.js +3 -1
  16. package/lib/models/DisplayItemModel.d.ts +1 -1
  17. package/lib/models/EditableItemModel.d.ts +1 -1
  18. package/lib/models/FilterableItemModel.d.ts +1 -1
  19. package/lib/runjs-context/setup.js +1 -0
  20. package/lib/server.d.ts +10 -0
  21. package/lib/server.js +32 -0
  22. package/lib/types.d.ts +233 -0
  23. package/package.json +4 -4
  24. package/server.d.ts +1 -0
  25. package/server.js +1 -0
  26. package/src/FlowSchemaRegistry.ts +1799 -0
  27. package/src/__tests__/FlowSchemaRegistry.test.ts +1951 -0
  28. package/src/__tests__/flow-engine.test.ts +48 -0
  29. package/src/__tests__/flowEngine.modelLoaders.test.ts +5 -1
  30. package/src/__tests__/flowEngine.saveModel.test.ts +4 -0
  31. package/src/__tests__/runjsContext.test.ts +3 -0
  32. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  33. package/src/components/subModel/AddSubModelButton.tsx +15 -1
  34. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +1 -0
  35. package/src/flow-schema-registry/fieldBinding.ts +171 -0
  36. package/src/flow-schema-registry/modelPatches.ts +260 -0
  37. package/src/flow-schema-registry/schemaInference.ts +210 -0
  38. package/src/flow-schema-registry/utils.ts +268 -0
  39. package/src/flowEngine.ts +7 -1
  40. package/src/index.ts +1 -0
  41. package/src/models/DisplayItemModel.tsx +1 -1
  42. package/src/models/EditableItemModel.tsx +1 -1
  43. package/src/models/FilterableItemModel.tsx +1 -1
  44. package/src/runjs-context/setup.ts +1 -0
  45. package/src/server.ts +11 -0
  46. package/src/types.ts +273 -0
@@ -0,0 +1,1951 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import { FlowSchemaRegistry } from '../FlowSchemaRegistry';
12
+ import { FlowModel } from '../models';
13
+
14
+ const expectGridLayoutSchemaDocument = (document: any) => {
15
+ expect(document?.jsonSchema?.properties?.stepParams).toMatchObject({
16
+ properties: {
17
+ gridSettings: {
18
+ type: 'object',
19
+ properties: {
20
+ grid: {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ properties: {
24
+ rows: {
25
+ type: 'object',
26
+ additionalProperties: {
27
+ type: 'array',
28
+ items: {
29
+ type: 'array',
30
+ items: {
31
+ type: 'string',
32
+ },
33
+ },
34
+ },
35
+ },
36
+ sizes: {
37
+ type: 'object',
38
+ additionalProperties: {
39
+ type: 'array',
40
+ items: {
41
+ type: 'number',
42
+ },
43
+ },
44
+ },
45
+ rowOrder: {
46
+ type: 'array',
47
+ items: {
48
+ type: 'string',
49
+ },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ });
57
+ };
58
+
59
+ const objectSchema = (
60
+ properties: Record<string, any> = {},
61
+ options: {
62
+ required?: string[];
63
+ additionalProperties?: boolean | Record<string, any>;
64
+ } = {},
65
+ ) => {
66
+ const { required = [], additionalProperties = true } = options;
67
+
68
+ return {
69
+ type: 'object',
70
+ properties,
71
+ ...(required.length ? { required } : {}),
72
+ additionalProperties,
73
+ };
74
+ };
75
+
76
+ const modelContribution = (use: string, extra: Record<string, any> = {}) => ({
77
+ use,
78
+ stepParamsSchema: objectSchema(),
79
+ ...extra,
80
+ });
81
+
82
+ const internalModelContribution = (use: string, uid: string, extra: Record<string, any> = {}) => ({
83
+ use,
84
+ exposure: 'internal',
85
+ stepParamsSchema: objectSchema(),
86
+ skeleton: {
87
+ uid,
88
+ use,
89
+ },
90
+ ...extra,
91
+ });
92
+
93
+ const objectSlot = (extra: Record<string, any> = {}) => ({
94
+ type: 'object',
95
+ ...extra,
96
+ });
97
+
98
+ const arraySlot = (extra: Record<string, any> = {}) => ({
99
+ type: 'array',
100
+ ...extra,
101
+ });
102
+
103
+ describe('FlowSchemaRegistry', () => {
104
+ it('should infer JSON schema from static uiSchema', () => {
105
+ const registry = new FlowSchemaRegistry();
106
+
107
+ registry.registerAction({
108
+ name: 'schemaRegistryStaticAction',
109
+ handler: () => null,
110
+ uiSchema: {
111
+ title: {
112
+ type: 'string',
113
+ required: true,
114
+ 'x-validator': {
115
+ minLength: 2,
116
+ },
117
+ } as any,
118
+ mode: {
119
+ enum: [
120
+ { label: 'A', value: 'a' },
121
+ { label: 'B', value: 'b' },
122
+ ],
123
+ } as any,
124
+ },
125
+ });
126
+
127
+ expect(registry.getAction('schemaRegistryStaticAction')?.schema).toMatchObject({
128
+ type: 'object',
129
+ properties: {
130
+ title: {
131
+ type: 'string',
132
+ minLength: 2,
133
+ },
134
+ mode: {
135
+ enum: ['a', 'b'],
136
+ },
137
+ },
138
+ required: ['title'],
139
+ additionalProperties: false,
140
+ });
141
+ expect(registry.getAction('schemaRegistryStaticAction')?.coverage.status).toBe('auto');
142
+ });
143
+
144
+ it('should apply action patch and step override with correct priority', () => {
145
+ const registry = new FlowSchemaRegistry();
146
+
147
+ registry.registerAction({
148
+ name: 'schemaRegistryPatchedAction',
149
+ handler: () => null,
150
+ uiSchema: {
151
+ enabled: {
152
+ type: 'boolean',
153
+ } as any,
154
+ },
155
+ paramsSchemaPatch: {
156
+ properties: {
157
+ label: {
158
+ type: 'string',
159
+ },
160
+ },
161
+ required: ['label'],
162
+ },
163
+ });
164
+
165
+ const patched = registry.resolveStepParamsSchema(
166
+ {
167
+ use: 'schemaRegistryPatchedAction',
168
+ },
169
+ 'SchemaRegistryPatchedModel.settings.save',
170
+ );
171
+
172
+ expect(patched.schema).toMatchObject({
173
+ type: 'object',
174
+ properties: {
175
+ enabled: {
176
+ type: 'boolean',
177
+ },
178
+ label: {
179
+ type: 'string',
180
+ },
181
+ },
182
+ required: ['label'],
183
+ additionalProperties: false,
184
+ });
185
+ expect(patched.coverage).toBe('mixed');
186
+
187
+ const overridden = registry.resolveStepParamsSchema(
188
+ {
189
+ use: 'schemaRegistryPatchedAction',
190
+ paramsSchemaOverride: {
191
+ type: 'object',
192
+ properties: {
193
+ only: {
194
+ type: 'string',
195
+ },
196
+ },
197
+ required: ['only'],
198
+ additionalProperties: false,
199
+ },
200
+ },
201
+ 'SchemaRegistryPatchedModel.settings.save',
202
+ );
203
+
204
+ expect(overridden.schema).toEqual({
205
+ type: 'object',
206
+ properties: {
207
+ only: {
208
+ type: 'string',
209
+ },
210
+ },
211
+ required: ['only'],
212
+ additionalProperties: false,
213
+ });
214
+ expect(overridden.coverage).toBe('manual');
215
+ });
216
+
217
+ it('should invalidate cached model documents after late action registration', () => {
218
+ class SchemaRegistryLateActionHostModel extends FlowModel {}
219
+
220
+ SchemaRegistryLateActionHostModel.define({
221
+ label: 'Late action host',
222
+ });
223
+ SchemaRegistryLateActionHostModel.registerFlow({
224
+ key: 'settings',
225
+ steps: {
226
+ save: {
227
+ use: 'schemaRegistryLateAction',
228
+ },
229
+ },
230
+ });
231
+
232
+ const registry = new FlowSchemaRegistry();
233
+ registry.registerModels({
234
+ SchemaRegistryLateActionHostModel,
235
+ });
236
+
237
+ const before = registry.getModelDocument('SchemaRegistryLateActionHostModel');
238
+ expect((before.jsonSchema.properties?.stepParams as any)?.properties?.settings?.properties?.save).toMatchObject({
239
+ type: 'object',
240
+ additionalProperties: true,
241
+ });
242
+
243
+ registry.registerActionContribution({
244
+ name: 'schemaRegistryLateAction',
245
+ paramsSchema: {
246
+ type: 'object',
247
+ properties: {
248
+ enabled: {
249
+ type: 'boolean',
250
+ },
251
+ },
252
+ required: ['enabled'],
253
+ additionalProperties: false,
254
+ },
255
+ });
256
+
257
+ const after = registry.getModelDocument('SchemaRegistryLateActionHostModel');
258
+ expect((after.jsonSchema.properties?.stepParams as any)?.properties?.settings?.properties?.save).toMatchObject({
259
+ type: 'object',
260
+ properties: {
261
+ enabled: {
262
+ type: 'boolean',
263
+ },
264
+ },
265
+ required: ['enabled'],
266
+ additionalProperties: false,
267
+ });
268
+ expect(after.hash).not.toBe(before.hash);
269
+ });
270
+
271
+ it('should keep existing coverage metadata when later model contributions only add docs', () => {
272
+ const registry = new FlowSchemaRegistry();
273
+
274
+ registry.registerModelContribution({
275
+ use: 'SchemaRegistryCoverageModel',
276
+ stepParamsSchema: objectSchema(),
277
+ source: 'official',
278
+ strict: true,
279
+ });
280
+
281
+ registry.registerModelContribution({
282
+ use: 'SchemaRegistryCoverageModel',
283
+ docs: {
284
+ description: 'docs only update',
285
+ },
286
+ source: 'plugin',
287
+ strict: false,
288
+ });
289
+
290
+ expect(registry.getModel('SchemaRegistryCoverageModel')?.coverage).toEqual({
291
+ status: 'manual',
292
+ source: 'official',
293
+ strict: true,
294
+ });
295
+ });
296
+
297
+ it('should treat flowRegistrySchemaPatch-only contributions as schema coverage', () => {
298
+ const registry = new FlowSchemaRegistry();
299
+
300
+ registry.registerModelContribution({
301
+ use: 'SchemaRegistryFlowRegistryPatchModel',
302
+ flowRegistrySchemaPatch: {
303
+ properties: {
304
+ eventFlow: {
305
+ type: 'object',
306
+ properties: {
307
+ enabled: {
308
+ type: 'boolean',
309
+ },
310
+ },
311
+ additionalProperties: false,
312
+ },
313
+ },
314
+ },
315
+ source: 'plugin',
316
+ strict: true,
317
+ });
318
+
319
+ expect(registry.getModel('SchemaRegistryFlowRegistryPatchModel')?.coverage).toEqual({
320
+ status: 'manual',
321
+ source: 'plugin',
322
+ strict: true,
323
+ });
324
+ expect(registry.buildStaticFlowRegistrySchema('SchemaRegistryFlowRegistryPatchModel')).toMatchObject({
325
+ properties: {
326
+ eventFlow: {
327
+ type: 'object',
328
+ properties: {
329
+ enabled: {
330
+ type: 'boolean',
331
+ },
332
+ },
333
+ additionalProperties: false,
334
+ },
335
+ },
336
+ });
337
+ });
338
+
339
+ it('should resolve slot use expansions from inventory without model-specific hardcoding', () => {
340
+ const registry = new FlowSchemaRegistry();
341
+
342
+ registry.registerModelContribution(
343
+ modelContribution('SchemaRegistryDeclaredSlotChildModel', {
344
+ title: 'Declared child',
345
+ }),
346
+ );
347
+ registry.registerModelContribution(
348
+ modelContribution('SchemaRegistryExpandedSlotChildModel', {
349
+ title: 'Expanded child',
350
+ }),
351
+ );
352
+ registry.registerModelContribution(
353
+ modelContribution('SchemaRegistrySlotExpansionParentModel', {
354
+ subModelSlots: {
355
+ items: arraySlot({
356
+ uses: ['SchemaRegistryDeclaredSlotChildModel'],
357
+ }),
358
+ },
359
+ }),
360
+ );
361
+ registry.registerInventory(
362
+ {
363
+ slotUseExpansions: [
364
+ {
365
+ parentUse: 'SchemaRegistrySlotExpansionParentModel',
366
+ slotKey: 'items',
367
+ uses: ['SchemaRegistryExpandedSlotChildModel', 'SchemaRegistryDeclaredSlotChildModel'],
368
+ },
369
+ ],
370
+ },
371
+ 'official',
372
+ );
373
+
374
+ expect(
375
+ registry.resolveSlotAllowedUses(
376
+ 'SchemaRegistrySlotExpansionParentModel',
377
+ 'items',
378
+ registry.getModel('SchemaRegistrySlotExpansionParentModel')?.subModelSlots?.items,
379
+ ),
380
+ ).toEqual(['SchemaRegistryDeclaredSlotChildModel', 'SchemaRegistryExpandedSlotChildModel']);
381
+ expect(registry.getSchemaBundle(['SchemaRegistrySlotExpansionParentModel']).items[0]).toMatchObject({
382
+ use: 'SchemaRegistrySlotExpansionParentModel',
383
+ subModelCatalog: {
384
+ items: {
385
+ type: 'array',
386
+ candidates: [
387
+ expect.objectContaining({ use: 'SchemaRegistryDeclaredSlotChildModel' }),
388
+ expect.objectContaining({ use: 'SchemaRegistryExpandedSlotChildModel' }),
389
+ ],
390
+ },
391
+ },
392
+ });
393
+ });
394
+
395
+ it('should accept public tree roots in inventory contributions', () => {
396
+ const registry = new FlowSchemaRegistry();
397
+
398
+ registry.registerModelContribution(
399
+ modelContribution('SchemaRegistryInventoryRootModel', {
400
+ exposure: 'internal',
401
+ }),
402
+ );
403
+
404
+ expect(() =>
405
+ registry.registerInventory(
406
+ {
407
+ publicTreeRoots: ['SchemaRegistryInventoryRootModel'],
408
+ },
409
+ 'official',
410
+ ),
411
+ ).not.toThrow();
412
+ expect(registry.hasQueryableModel('SchemaRegistryInventoryRootModel')).toBe(true);
413
+ expect(registry.listPublicTreeRoots()).toEqual(['SchemaRegistryInventoryRootModel']);
414
+ });
415
+
416
+ it('should build document schema, aggregate flow hints, and infer sub-model slots', () => {
417
+ class SchemaRegistryChildModel extends FlowModel {}
418
+ class SchemaRegistryParentModel extends FlowModel {}
419
+
420
+ SchemaRegistryChildModel.define({
421
+ label: 'Schema child',
422
+ });
423
+ SchemaRegistryParentModel.define({
424
+ label: 'Schema parent',
425
+ createModelOptions: {
426
+ use: 'SchemaRegistryParentModel',
427
+ subModels: {
428
+ header: {
429
+ use: 'SchemaRegistryChildModel',
430
+ },
431
+ items: [
432
+ {
433
+ use: 'SchemaRegistryChildModel',
434
+ },
435
+ ],
436
+ },
437
+ },
438
+ });
439
+ SchemaRegistryParentModel.registerFlow({
440
+ key: 'settings',
441
+ steps: {
442
+ rename: {
443
+ use: 'schemaRegistryRenameAction',
444
+ },
445
+ dynamic: {
446
+ use: 'schemaRegistryDynamicAction',
447
+ },
448
+ },
449
+ });
450
+
451
+ const registry = new FlowSchemaRegistry();
452
+ registry.registerActions({
453
+ schemaRegistryRenameAction: {
454
+ name: 'schemaRegistryRenameAction',
455
+ handler: () => null,
456
+ paramsSchema: {
457
+ type: 'object',
458
+ properties: {
459
+ title: {
460
+ type: 'string',
461
+ },
462
+ },
463
+ required: ['title'],
464
+ additionalProperties: false,
465
+ },
466
+ },
467
+ schemaRegistryDynamicAction: {
468
+ name: 'schemaRegistryDynamicAction',
469
+ handler: () => null,
470
+ uiSchema: (() => ({
471
+ enabled: {
472
+ type: 'boolean',
473
+ },
474
+ })) as any,
475
+ },
476
+ });
477
+ registry.registerModels({
478
+ SchemaRegistryChildModel,
479
+ SchemaRegistryParentModel,
480
+ });
481
+
482
+ const doc = registry.getModelDocument('SchemaRegistryParentModel');
483
+
484
+ expect((doc.jsonSchema.properties?.subModels as any)?.properties?.header).toMatchObject({
485
+ type: 'object',
486
+ properties: {
487
+ use: {
488
+ const: 'SchemaRegistryChildModel',
489
+ },
490
+ },
491
+ });
492
+ expect((doc.jsonSchema.properties?.subModels as any)?.properties?.items).toMatchObject({
493
+ type: 'array',
494
+ });
495
+ expect((doc.jsonSchema.properties?.stepParams as any)?.properties?.settings?.properties?.rename).toMatchObject({
496
+ properties: {
497
+ title: {
498
+ type: 'string',
499
+ },
500
+ },
501
+ required: ['title'],
502
+ additionalProperties: false,
503
+ });
504
+ expect(doc.dynamicHints).toEqual(
505
+ expect.arrayContaining([
506
+ expect.objectContaining({
507
+ kind: 'manual-schema-required',
508
+ path: 'SchemaRegistryParentModel.subModels.header',
509
+ 'x-flow': expect.objectContaining({
510
+ slotRules: expect.objectContaining({
511
+ slotKey: 'header',
512
+ type: 'object',
513
+ allowedUses: ['SchemaRegistryChildModel'],
514
+ }),
515
+ }),
516
+ }),
517
+ expect.objectContaining({
518
+ kind: 'manual-schema-required',
519
+ path: 'SchemaRegistryParentModel.subModels.items',
520
+ 'x-flow': expect.objectContaining({
521
+ slotRules: expect.objectContaining({
522
+ slotKey: 'items',
523
+ type: 'array',
524
+ allowedUses: ['SchemaRegistryChildModel'],
525
+ }),
526
+ }),
527
+ }),
528
+ expect.objectContaining({
529
+ kind: 'dynamic-ui-schema',
530
+ path: 'actions.schemaRegistryDynamicAction',
531
+ 'x-flow': expect.objectContaining({
532
+ unresolvedReason: 'function-ui-schema',
533
+ }),
534
+ }),
535
+ ]),
536
+ );
537
+ expect(doc.coverage.status).toBe('mixed');
538
+ });
539
+
540
+ it('should emit dynamic child hints for function-based model metadata', () => {
541
+ class SchemaRegistryDynamicModel extends FlowModel {}
542
+
543
+ SchemaRegistryDynamicModel.define({
544
+ label: 'Schema dynamic',
545
+ children: (async () => []) as any,
546
+ createModelOptions: (() => ({
547
+ use: 'SchemaRegistryDynamicModel',
548
+ })) as any,
549
+ });
550
+
551
+ const registry = new FlowSchemaRegistry();
552
+ registry.registerModels({
553
+ SchemaRegistryDynamicModel,
554
+ });
555
+
556
+ const doc = registry.getModelDocument('SchemaRegistryDynamicModel');
557
+
558
+ expect(doc.dynamicHints).toEqual(
559
+ expect.arrayContaining([
560
+ expect.objectContaining({
561
+ kind: 'dynamic-children',
562
+ path: 'SchemaRegistryDynamicModel.meta.children',
563
+ }),
564
+ expect.objectContaining({
565
+ kind: 'dynamic-children',
566
+ path: 'SchemaRegistryDynamicModel.meta.createModelOptions',
567
+ }),
568
+ ]),
569
+ );
570
+ });
571
+
572
+ it('should preserve nested grid layout step params schema in model documents', () => {
573
+ const registry = new FlowSchemaRegistry();
574
+
575
+ registry.registerModelContribution({
576
+ use: 'SchemaRegistryGridModel',
577
+ stepParamsSchema: {
578
+ type: 'object',
579
+ properties: {
580
+ gridSettings: {
581
+ type: 'object',
582
+ properties: {
583
+ grid: {
584
+ type: 'object',
585
+ properties: {
586
+ rows: {
587
+ type: 'object',
588
+ additionalProperties: {
589
+ type: 'array',
590
+ items: {
591
+ type: 'array',
592
+ items: {
593
+ type: 'string',
594
+ },
595
+ },
596
+ },
597
+ },
598
+ sizes: {
599
+ type: 'object',
600
+ additionalProperties: {
601
+ type: 'array',
602
+ items: {
603
+ type: 'number',
604
+ },
605
+ },
606
+ },
607
+ rowOrder: {
608
+ type: 'array',
609
+ items: {
610
+ type: 'string',
611
+ },
612
+ },
613
+ },
614
+ additionalProperties: false,
615
+ },
616
+ },
617
+ additionalProperties: true,
618
+ },
619
+ },
620
+ additionalProperties: true,
621
+ },
622
+ });
623
+
624
+ const doc = registry.getModelDocument('SchemaRegistryGridModel');
625
+
626
+ expectGridLayoutSchemaDocument(doc);
627
+ });
628
+
629
+ it('should build bundle-friendly documents from pure data contributions', () => {
630
+ const registry = new FlowSchemaRegistry();
631
+
632
+ registry.registerActionContribution({
633
+ name: 'schemaRegistryContributionAction',
634
+ title: 'Contribution action',
635
+ paramsSchema: {
636
+ type: 'object',
637
+ properties: {
638
+ enabled: {
639
+ type: 'boolean',
640
+ },
641
+ },
642
+ additionalProperties: false,
643
+ },
644
+ docs: {
645
+ minimalExample: {
646
+ enabled: true,
647
+ },
648
+ },
649
+ });
650
+
651
+ registry.registerModelContribution({
652
+ use: 'SchemaRegistryContributionModel',
653
+ title: 'Contribution model',
654
+ stepParamsSchema: {
655
+ type: 'object',
656
+ properties: {
657
+ settings: {
658
+ type: 'object',
659
+ properties: {
660
+ title: {
661
+ type: 'string',
662
+ },
663
+ toggle: {
664
+ type: 'object',
665
+ properties: {
666
+ enabled: {
667
+ type: 'boolean',
668
+ },
669
+ },
670
+ additionalProperties: false,
671
+ },
672
+ },
673
+ required: ['title'],
674
+ additionalProperties: false,
675
+ },
676
+ },
677
+ additionalProperties: true,
678
+ },
679
+ subModelSlots: {
680
+ body: {
681
+ type: 'array',
682
+ uses: ['SchemaRegistryChildModel'],
683
+ schema: {
684
+ type: 'object',
685
+ required: ['uid', 'use'],
686
+ properties: {
687
+ uid: { type: 'string' },
688
+ use: { type: 'string' },
689
+ },
690
+ additionalProperties: true,
691
+ },
692
+ },
693
+ },
694
+ docs: {
695
+ minimalExample: {
696
+ uid: 'contribution-model-1',
697
+ use: 'SchemaRegistryContributionModel',
698
+ stepParams: {
699
+ settings: {
700
+ title: 'Contribution model',
701
+ },
702
+ },
703
+ },
704
+ commonPatterns: [
705
+ {
706
+ title: 'Minimal contribution model',
707
+ snippet: {
708
+ stepParams: {
709
+ settings: {
710
+ title: 'Contribution model',
711
+ },
712
+ },
713
+ },
714
+ },
715
+ ],
716
+ antiPatterns: [
717
+ {
718
+ title: 'Missing title',
719
+ },
720
+ ],
721
+ dynamicHints: [
722
+ {
723
+ kind: 'dynamic-children',
724
+ path: 'SchemaRegistryContributionModel.subModels.body',
725
+ message: 'body slot is curated manually.',
726
+ 'x-flow': {
727
+ slotRules: {
728
+ slotKey: 'body',
729
+ type: 'array',
730
+ allowedUses: ['SchemaRegistryChildModel'],
731
+ },
732
+ },
733
+ },
734
+ ],
735
+ },
736
+ });
737
+
738
+ const doc = registry.getModelDocument('SchemaRegistryContributionModel');
739
+ const bundle = registry.getSchemaBundle(['SchemaRegistryContributionModel']);
740
+
741
+ expect(doc.minimalExample).toMatchObject({
742
+ use: 'SchemaRegistryContributionModel',
743
+ });
744
+ expect(doc.skeleton).toMatchObject({
745
+ uid: 'todo-uid',
746
+ use: 'SchemaRegistryContributionModel',
747
+ });
748
+ expect(doc.commonPatterns).toEqual(
749
+ expect.arrayContaining([
750
+ expect.objectContaining({
751
+ title: 'Minimal contribution model',
752
+ }),
753
+ ]),
754
+ );
755
+ expect(doc.dynamicHints).toEqual(
756
+ expect.arrayContaining([
757
+ expect.objectContaining({
758
+ path: 'SchemaRegistryContributionModel.subModels.body',
759
+ 'x-flow': expect.objectContaining({
760
+ slotRules: expect.objectContaining({
761
+ allowedUses: ['SchemaRegistryChildModel'],
762
+ }),
763
+ }),
764
+ }),
765
+ ]),
766
+ );
767
+ expect(bundle).not.toHaveProperty('generatedAt');
768
+ expect(bundle).not.toHaveProperty('summary');
769
+ expect(bundle.items[0]).toMatchObject({
770
+ use: 'SchemaRegistryContributionModel',
771
+ subModelCatalog: {
772
+ body: expect.objectContaining({
773
+ type: 'array',
774
+ candidates: [expect.objectContaining({ use: 'SchemaRegistryChildModel' })],
775
+ }),
776
+ },
777
+ });
778
+ expect(
779
+ Object.keys(bundle.items[0] || {}).filter((key) => !['use', 'title', 'subModelCatalog'].includes(key)),
780
+ ).toEqual([]);
781
+ });
782
+
783
+ it('should truncate recursive ancestor snapshots in model documents', () => {
784
+ const registry = new FlowSchemaRegistry();
785
+
786
+ registry.registerModelContribution({
787
+ use: 'SchemaRegistryLoopRootModel',
788
+ subModelSlots: {
789
+ actions: {
790
+ type: 'array',
791
+ uses: ['SchemaRegistryLoopActionModel'],
792
+ },
793
+ },
794
+ });
795
+ registry.registerModelContribution({
796
+ use: 'SchemaRegistryLoopActionModel',
797
+ subModelSlots: {
798
+ page: {
799
+ type: 'object',
800
+ use: 'SchemaRegistryLoopPageModel',
801
+ },
802
+ },
803
+ });
804
+ registry.registerModelContribution({
805
+ use: 'SchemaRegistryLoopPageModel',
806
+ subModelSlots: {
807
+ tabs: {
808
+ type: 'array',
809
+ uses: ['SchemaRegistryLoopTabModel'],
810
+ },
811
+ },
812
+ });
813
+ registry.registerModelContribution({
814
+ use: 'SchemaRegistryLoopTabModel',
815
+ subModelSlots: {
816
+ grid: {
817
+ type: 'object',
818
+ use: 'SchemaRegistryLoopGridModel',
819
+ },
820
+ },
821
+ });
822
+ registry.registerModelContribution({
823
+ use: 'SchemaRegistryLoopGridModel',
824
+ subModelSlots: {
825
+ items: {
826
+ type: 'array',
827
+ uses: ['SchemaRegistryLoopRootModel'],
828
+ },
829
+ },
830
+ });
831
+
832
+ const doc = registry.getModelDocument('SchemaRegistryLoopRootModel');
833
+ const rootSubModels = (doc.jsonSchema.properties?.subModels as any)?.properties;
834
+ const actionNode = rootSubModels?.actions?.items;
835
+ const actionSubModels = actionNode?.properties?.subModels?.properties;
836
+ const pageNode = actionSubModels?.page;
837
+ const pageSubModels = pageNode?.properties?.subModels?.properties;
838
+ const tabNode = pageSubModels?.tabs?.items;
839
+ const tabSubModels = tabNode?.properties?.subModels?.properties;
840
+ const gridNode = tabSubModels?.grid;
841
+ const gridSubModels = gridNode?.properties?.subModels?.properties;
842
+ const recursiveRoot = gridSubModels?.items?.items;
843
+
844
+ expect(recursiveRoot).toMatchObject({
845
+ type: 'object',
846
+ properties: {
847
+ use: {
848
+ const: 'SchemaRegistryLoopRootModel',
849
+ },
850
+ subModels: {
851
+ type: 'object',
852
+ additionalProperties: true,
853
+ },
854
+ },
855
+ });
856
+ expect((recursiveRoot?.properties?.subModels as any)?.properties).toBeUndefined();
857
+ });
858
+
859
+ it('should treat abstract models as non-queryable while allowing explicit internal concrete models', () => {
860
+ const registry = new FlowSchemaRegistry();
861
+
862
+ registry.registerModelContribution({
863
+ use: 'SchemaRegistryInternalBaseModel',
864
+ exposure: 'internal',
865
+ abstract: true,
866
+ allowDirectUse: false,
867
+ suggestedUses: ['SchemaRegistryPublicModel'],
868
+ });
869
+ registry.registerModelContribution({
870
+ use: 'SchemaRegistryInternalConcreteModel',
871
+ exposure: 'internal',
872
+ stepParamsSchema: {
873
+ type: 'object',
874
+ properties: {
875
+ enabled: {
876
+ type: 'boolean',
877
+ },
878
+ },
879
+ additionalProperties: true,
880
+ },
881
+ });
882
+ registry.registerModelContribution({
883
+ use: 'SchemaRegistryPublicModel',
884
+ stepParamsSchema: {
885
+ type: 'object',
886
+ properties: {
887
+ settings: {
888
+ type: 'object',
889
+ additionalProperties: true,
890
+ },
891
+ },
892
+ additionalProperties: true,
893
+ },
894
+ });
895
+
896
+ expect(registry.hasQueryableModel('SchemaRegistryInternalBaseModel')).toBe(false);
897
+ expect(registry.hasQueryableModel('SchemaRegistryInternalConcreteModel')).toBe(true);
898
+ expect(registry.isDirectUseAllowed('SchemaRegistryInternalBaseModel')).toBe(false);
899
+ expect(registry.isDirectUseAllowed('SchemaRegistryInternalConcreteModel')).toBe(true);
900
+ expect(registry.getSuggestedUses('SchemaRegistryInternalBaseModel')).toEqual(['SchemaRegistryPublicModel']);
901
+ expect(registry.listModelUses({ publicOnly: true })).toEqual(['SchemaRegistryPublicModel']);
902
+ expect(registry.getSchemaBundle().items).toEqual([]);
903
+ expect(registry.getSchemaBundle(['SchemaRegistryInternalConcreteModel']).items.map((item) => item.use)).toEqual([
904
+ 'SchemaRegistryInternalConcreteModel',
905
+ ]);
906
+ });
907
+
908
+ it('should resolve direct child schema patches by parent slot context', () => {
909
+ const registry = new FlowSchemaRegistry();
910
+
911
+ registry.registerModelContribution({
912
+ use: 'SchemaRegistryContextChildModel',
913
+ stepParamsSchema: {
914
+ type: 'object',
915
+ properties: {
916
+ shared: {
917
+ type: 'string',
918
+ },
919
+ },
920
+ additionalProperties: true,
921
+ },
922
+ source: 'official',
923
+ strict: true,
924
+ });
925
+
926
+ registry.registerModelContribution({
927
+ use: 'SchemaRegistryParentAlphaModel',
928
+ source: 'official',
929
+ strict: true,
930
+ subModelSlots: {
931
+ body: {
932
+ type: 'object',
933
+ use: 'SchemaRegistryContextChildModel',
934
+ childSchemaPatch: {
935
+ stepParamsSchema: {
936
+ type: 'object',
937
+ properties: {
938
+ alpha: {
939
+ type: 'string',
940
+ },
941
+ },
942
+ required: ['alpha'],
943
+ additionalProperties: false,
944
+ },
945
+ },
946
+ },
947
+ },
948
+ });
949
+
950
+ registry.registerModelContribution({
951
+ use: 'SchemaRegistryParentBetaModel',
952
+ source: 'official',
953
+ strict: true,
954
+ subModelSlots: {
955
+ body: {
956
+ type: 'object',
957
+ use: 'SchemaRegistryContextChildModel',
958
+ childSchemaPatch: {
959
+ stepParamsSchema: {
960
+ type: 'object',
961
+ properties: {
962
+ beta: {
963
+ type: 'number',
964
+ },
965
+ },
966
+ required: ['beta'],
967
+ additionalProperties: false,
968
+ },
969
+ },
970
+ },
971
+ },
972
+ });
973
+
974
+ expect(
975
+ registry.getModelDocument('SchemaRegistryContextChildModel').jsonSchema.properties?.stepParams,
976
+ ).toMatchObject({
977
+ properties: {
978
+ shared: {
979
+ type: 'string',
980
+ },
981
+ },
982
+ additionalProperties: true,
983
+ });
984
+
985
+ expect(
986
+ registry.resolveModelSchema('SchemaRegistryContextChildModel', [
987
+ {
988
+ parentUse: 'SchemaRegistryParentAlphaModel',
989
+ slotKey: 'body',
990
+ childUse: 'SchemaRegistryContextChildModel',
991
+ },
992
+ ]).stepParamsSchema,
993
+ ).toMatchObject({
994
+ properties: {
995
+ shared: {
996
+ type: 'string',
997
+ },
998
+ alpha: {
999
+ type: 'string',
1000
+ },
1001
+ },
1002
+ required: ['alpha'],
1003
+ additionalProperties: false,
1004
+ });
1005
+
1006
+ expect(
1007
+ (registry.getModelDocument('SchemaRegistryParentAlphaModel').jsonSchema.properties?.subModels as any)?.properties
1008
+ ?.body?.properties?.stepParams,
1009
+ ).toMatchObject({
1010
+ properties: {
1011
+ alpha: {
1012
+ type: 'string',
1013
+ },
1014
+ },
1015
+ required: ['alpha'],
1016
+ additionalProperties: false,
1017
+ });
1018
+
1019
+ expect(
1020
+ (registry.getModelDocument('SchemaRegistryParentBetaModel').jsonSchema.properties?.subModels as any)?.properties
1021
+ ?.body?.properties?.stepParams,
1022
+ ).toMatchObject({
1023
+ properties: {
1024
+ beta: {
1025
+ type: 'number',
1026
+ },
1027
+ },
1028
+ required: ['beta'],
1029
+ additionalProperties: false,
1030
+ });
1031
+
1032
+ const bundle = registry.getSchemaBundle(['SchemaRegistryParentAlphaModel', 'SchemaRegistryParentBetaModel']);
1033
+ const alphaItem = bundle.items.find((item) => item.use === 'SchemaRegistryParentAlphaModel');
1034
+ const betaItem = bundle.items.find((item) => item.use === 'SchemaRegistryParentBetaModel');
1035
+
1036
+ expect(alphaItem?.subModelCatalog?.body?.candidates?.map((item) => item.use)).toEqual([
1037
+ 'SchemaRegistryContextChildModel',
1038
+ ]);
1039
+ expect(betaItem?.subModelCatalog?.body?.candidates?.map((item) => item.use)).toEqual([
1040
+ 'SchemaRegistryContextChildModel',
1041
+ ]);
1042
+ });
1043
+
1044
+ it('should apply ancestor descendant patches before direct child patches', () => {
1045
+ const registry = new FlowSchemaRegistry();
1046
+
1047
+ registry.registerModelContribution({
1048
+ use: 'SchemaRegistryDescLeafModel',
1049
+ stepParamsSchema: {
1050
+ type: 'object',
1051
+ additionalProperties: true,
1052
+ },
1053
+ source: 'official',
1054
+ strict: true,
1055
+ });
1056
+
1057
+ registry.registerModelContribution({
1058
+ use: 'SchemaRegistryDescParentModel',
1059
+ source: 'official',
1060
+ strict: true,
1061
+ subModelSlots: {
1062
+ section: {
1063
+ type: 'object',
1064
+ use: 'SchemaRegistryDescBridgeModel',
1065
+ childSchemaPatch: {
1066
+ subModelSlots: {
1067
+ leaf: {
1068
+ type: 'object',
1069
+ use: 'SchemaRegistryDescLeafModel',
1070
+ childSchemaPatch: {
1071
+ stepParamsSchema: {
1072
+ type: 'object',
1073
+ properties: {
1074
+ marker: {
1075
+ type: 'string',
1076
+ },
1077
+ directOnly: {
1078
+ type: 'string',
1079
+ },
1080
+ },
1081
+ required: ['directOnly'],
1082
+ additionalProperties: false,
1083
+ },
1084
+ },
1085
+ },
1086
+ },
1087
+ },
1088
+ descendantSchemaPatches: [
1089
+ {
1090
+ path: [
1091
+ {
1092
+ slotKey: 'leaf',
1093
+ use: 'SchemaRegistryDescLeafModel',
1094
+ },
1095
+ ],
1096
+ patch: {
1097
+ stepParamsSchema: {
1098
+ type: 'object',
1099
+ properties: {
1100
+ marker: {
1101
+ type: 'number',
1102
+ },
1103
+ ancestorOnly: {
1104
+ type: 'boolean',
1105
+ },
1106
+ },
1107
+ required: ['ancestorOnly'],
1108
+ additionalProperties: true,
1109
+ },
1110
+ },
1111
+ },
1112
+ ],
1113
+ },
1114
+ },
1115
+ });
1116
+
1117
+ const resolved = registry.resolveModelSchema('SchemaRegistryDescLeafModel', [
1118
+ {
1119
+ parentUse: 'SchemaRegistryDescParentModel',
1120
+ slotKey: 'section',
1121
+ childUse: 'SchemaRegistryDescBridgeModel',
1122
+ },
1123
+ {
1124
+ parentUse: 'SchemaRegistryDescBridgeModel',
1125
+ slotKey: 'leaf',
1126
+ childUse: 'SchemaRegistryDescLeafModel',
1127
+ },
1128
+ ]);
1129
+
1130
+ expect(resolved.stepParamsSchema).toMatchObject({
1131
+ properties: {
1132
+ ancestorOnly: {
1133
+ type: 'boolean',
1134
+ },
1135
+ directOnly: {
1136
+ type: 'string',
1137
+ },
1138
+ marker: {
1139
+ type: 'string',
1140
+ },
1141
+ },
1142
+ required: ['directOnly'],
1143
+ additionalProperties: false,
1144
+ });
1145
+
1146
+ expect(
1147
+ (registry.getModelDocument('SchemaRegistryDescParentModel').jsonSchema.properties?.subModels as any)?.properties
1148
+ ?.section?.properties?.subModels?.properties?.leaf?.properties?.stepParams,
1149
+ ).toMatchObject({
1150
+ properties: {
1151
+ ancestorOnly: {
1152
+ type: 'boolean',
1153
+ },
1154
+ directOnly: {
1155
+ type: 'string',
1156
+ },
1157
+ },
1158
+ required: ['directOnly'],
1159
+ additionalProperties: false,
1160
+ });
1161
+ });
1162
+
1163
+ it('should only use legacy slot schema as fallback when no child use can be resolved', () => {
1164
+ const registry = new FlowSchemaRegistry();
1165
+
1166
+ registry.registerModelContribution({
1167
+ use: 'SchemaRegistryLegacyChildModel',
1168
+ stepParamsSchema: {
1169
+ type: 'object',
1170
+ properties: {
1171
+ title: {
1172
+ type: 'string',
1173
+ },
1174
+ },
1175
+ required: ['title'],
1176
+ additionalProperties: false,
1177
+ },
1178
+ });
1179
+
1180
+ registry.registerModelContribution({
1181
+ use: 'SchemaRegistryLegacyParentModel',
1182
+ subModelSlots: {
1183
+ body: {
1184
+ type: 'object',
1185
+ use: 'SchemaRegistryLegacyChildModel',
1186
+ schema: {
1187
+ type: 'object',
1188
+ required: ['uid', 'use'],
1189
+ properties: {
1190
+ uid: { type: 'string' },
1191
+ use: { type: 'string' },
1192
+ },
1193
+ additionalProperties: true,
1194
+ },
1195
+ },
1196
+ },
1197
+ });
1198
+
1199
+ expect(
1200
+ (registry.getModelDocument('SchemaRegistryLegacyParentModel').jsonSchema.properties?.subModels as any)?.properties
1201
+ ?.body,
1202
+ ).toMatchObject({
1203
+ anyOf: expect.arrayContaining([
1204
+ expect.objectContaining({
1205
+ properties: expect.objectContaining({
1206
+ use: {
1207
+ const: 'SchemaRegistryLegacyChildModel',
1208
+ },
1209
+ stepParams: expect.objectContaining({
1210
+ required: ['title'],
1211
+ additionalProperties: false,
1212
+ }),
1213
+ }),
1214
+ }),
1215
+ expect.objectContaining({
1216
+ properties: expect.objectContaining({
1217
+ use: {
1218
+ type: 'string',
1219
+ },
1220
+ }),
1221
+ }),
1222
+ ]),
1223
+ });
1224
+ });
1225
+
1226
+ it('should expose anonymous child snapshot patches when slot use is unresolved', () => {
1227
+ const registry = new FlowSchemaRegistry();
1228
+
1229
+ registry.registerModelContribution({
1230
+ use: 'SchemaRegistryAnonymousGridModel',
1231
+ stepParamsSchema: {
1232
+ type: 'object',
1233
+ properties: {
1234
+ title: {
1235
+ type: 'string',
1236
+ },
1237
+ },
1238
+ required: ['title'],
1239
+ additionalProperties: false,
1240
+ },
1241
+ });
1242
+
1243
+ registry.registerModelContribution({
1244
+ use: 'SchemaRegistryAnonymousParentModel',
1245
+ subModelSlots: {
1246
+ body: {
1247
+ type: 'object',
1248
+ childSchemaPatch: {
1249
+ stepParamsSchema: {
1250
+ type: 'object',
1251
+ properties: {
1252
+ mode: {
1253
+ type: 'string',
1254
+ enum: ['compact', 'full'],
1255
+ },
1256
+ },
1257
+ required: ['mode'],
1258
+ additionalProperties: false,
1259
+ },
1260
+ subModelSlots: {
1261
+ grid: {
1262
+ type: 'object',
1263
+ use: 'SchemaRegistryAnonymousGridModel',
1264
+ },
1265
+ },
1266
+ },
1267
+ },
1268
+ },
1269
+ });
1270
+
1271
+ expect(
1272
+ (registry.getModelDocument('SchemaRegistryAnonymousParentModel').jsonSchema.properties?.subModels as any)
1273
+ ?.properties?.body,
1274
+ ).toMatchObject({
1275
+ properties: {
1276
+ use: {
1277
+ type: 'string',
1278
+ },
1279
+ stepParams: {
1280
+ properties: {
1281
+ mode: {
1282
+ type: 'string',
1283
+ enum: ['compact', 'full'],
1284
+ },
1285
+ },
1286
+ required: ['mode'],
1287
+ additionalProperties: false,
1288
+ },
1289
+ subModels: {
1290
+ properties: {
1291
+ grid: {
1292
+ properties: {
1293
+ use: {
1294
+ const: 'SchemaRegistryAnonymousGridModel',
1295
+ },
1296
+ stepParams: {
1297
+ required: ['title'],
1298
+ additionalProperties: false,
1299
+ },
1300
+ },
1301
+ },
1302
+ },
1303
+ },
1304
+ },
1305
+ });
1306
+
1307
+ const bundle = registry.getSchemaBundle(['SchemaRegistryAnonymousParentModel']);
1308
+ expect(bundle.items[0]).toMatchObject({
1309
+ use: 'SchemaRegistryAnonymousParentModel',
1310
+ subModelCatalog: {
1311
+ body: {
1312
+ type: 'object',
1313
+ open: true,
1314
+ candidates: [],
1315
+ },
1316
+ },
1317
+ });
1318
+ });
1319
+
1320
+ it('should expose recursive sub-model catalogs for internal descendants without promoting them to top-level items', () => {
1321
+ const registry = new FlowSchemaRegistry();
1322
+
1323
+ registry.registerModelContribution(
1324
+ internalModelContribution('SchemaRegistryBundleLeafModel', 'bundle-leaf-uid', {
1325
+ allowDirectUse: false,
1326
+ stepParamsSchema: objectSchema(
1327
+ {
1328
+ label: {
1329
+ type: 'string',
1330
+ },
1331
+ },
1332
+ { required: ['label'], additionalProperties: false },
1333
+ ),
1334
+ }),
1335
+ );
1336
+
1337
+ registry.registerModelContribution(
1338
+ internalModelContribution('SchemaRegistryBundleChildModel', 'bundle-child-uid', {
1339
+ allowDirectUse: false,
1340
+ skeleton: {
1341
+ uid: 'bundle-child-uid',
1342
+ use: 'SchemaRegistryBundleChildModel',
1343
+ subModels: {
1344
+ items: [],
1345
+ dynamicZone: [],
1346
+ },
1347
+ },
1348
+ subModelSlots: {
1349
+ items: arraySlot({
1350
+ uses: ['SchemaRegistryBundleLeafModel'],
1351
+ }),
1352
+ dynamicZone: arraySlot(),
1353
+ },
1354
+ }),
1355
+ );
1356
+
1357
+ registry.registerModelContribution(
1358
+ modelContribution('SchemaRegistryBundleParentModel', {
1359
+ skeleton: {
1360
+ uid: 'bundle-parent-uid',
1361
+ use: 'SchemaRegistryBundleParentModel',
1362
+ subModels: {
1363
+ body: {
1364
+ use: 'SchemaRegistryBundleChildModel',
1365
+ },
1366
+ },
1367
+ },
1368
+ subModelSlots: {
1369
+ body: objectSlot({
1370
+ use: 'SchemaRegistryBundleChildModel',
1371
+ }),
1372
+ },
1373
+ }),
1374
+ );
1375
+
1376
+ const publicBundle = registry.getSchemaBundle();
1377
+ const bundle = registry.getSchemaBundle(['SchemaRegistryBundleParentModel']);
1378
+ const explicitInternalBundle = registry.getSchemaBundle(['SchemaRegistryBundleChildModel']);
1379
+
1380
+ expect(publicBundle.items).toEqual([]);
1381
+ expect(explicitInternalBundle.items.map((item) => item.use)).toEqual(['SchemaRegistryBundleChildModel']);
1382
+ expect(bundle.items[0]).toMatchObject({
1383
+ use: 'SchemaRegistryBundleParentModel',
1384
+ subModelCatalog: {
1385
+ body: {
1386
+ type: 'object',
1387
+ candidates: [
1388
+ expect.objectContaining({
1389
+ use: 'SchemaRegistryBundleChildModel',
1390
+ subModelCatalog: {
1391
+ items: {
1392
+ type: 'array',
1393
+ candidates: [expect.objectContaining({ use: 'SchemaRegistryBundleLeafModel' })],
1394
+ },
1395
+ dynamicZone: {
1396
+ type: 'array',
1397
+ open: true,
1398
+ candidates: [],
1399
+ },
1400
+ },
1401
+ }),
1402
+ ],
1403
+ },
1404
+ },
1405
+ });
1406
+ });
1407
+
1408
+ it('should resolve runtime field binding candidates with compatibility metadata', () => {
1409
+ const registry = new FlowSchemaRegistry();
1410
+
1411
+ registry.registerFieldBindingContexts([
1412
+ { name: 'editable-field' },
1413
+ { name: 'display-field' },
1414
+ { name: 'filter-field' },
1415
+ { name: 'table-column-field', inherits: ['display-field'] },
1416
+ { name: 'form-item-field', inherits: ['editable-field'] },
1417
+ ]);
1418
+
1419
+ registry.registerModelContribution(internalModelContribution('InputFieldModel', 'input-field-uid'));
1420
+ registry.registerModelContribution(internalModelContribution('DisplayTextFieldModel', 'display-text-field-uid'));
1421
+ registry.registerModelContribution(internalModelContribution('RecordSelectFieldModel', 'record-select-field-uid'));
1422
+ registry.registerModelContribution(
1423
+ internalModelContribution('CascadeSelectFieldModel', 'cascade-select-field-uid'),
1424
+ );
1425
+ registry.registerFieldBindings([
1426
+ {
1427
+ context: 'editable-field',
1428
+ use: 'InputFieldModel',
1429
+ interfaces: ['input'],
1430
+ isDefault: true,
1431
+ },
1432
+ {
1433
+ context: 'display-field',
1434
+ use: 'DisplayTextFieldModel',
1435
+ interfaces: ['input'],
1436
+ isDefault: true,
1437
+ },
1438
+ {
1439
+ context: 'editable-field',
1440
+ use: 'RecordSelectFieldModel',
1441
+ interfaces: ['m2o'],
1442
+ isDefault: true,
1443
+ conditions: {
1444
+ association: true,
1445
+ },
1446
+ },
1447
+ {
1448
+ context: 'editable-field',
1449
+ use: 'CascadeSelectFieldModel',
1450
+ interfaces: ['m2o'],
1451
+ isDefault: true,
1452
+ order: 60,
1453
+ conditions: {
1454
+ association: true,
1455
+ targetCollectionTemplateIn: ['tree'],
1456
+ },
1457
+ },
1458
+ ]);
1459
+
1460
+ registry.registerModelContribution(
1461
+ modelContribution('SchemaRegistryFieldHostModel', {
1462
+ skeleton: {
1463
+ uid: 'field-host-uid',
1464
+ use: 'SchemaRegistryFieldHostModel',
1465
+ },
1466
+ subModelSlots: {
1467
+ field: objectSlot({
1468
+ fieldBindingContext: 'form-item-field',
1469
+ }),
1470
+ },
1471
+ }),
1472
+ );
1473
+ registry.registerModelContribution(
1474
+ modelContribution('SchemaRegistryDisplayFieldHostModel', {
1475
+ skeleton: {
1476
+ uid: 'display-field-host-uid',
1477
+ use: 'SchemaRegistryDisplayFieldHostModel',
1478
+ },
1479
+ subModelSlots: {
1480
+ field: objectSlot({
1481
+ fieldBindingContext: 'table-column-field',
1482
+ }),
1483
+ },
1484
+ }),
1485
+ );
1486
+
1487
+ expect(
1488
+ registry.resolveFieldBindingCandidates('form-item-field', { interface: 'input' }).map((item) => item.use),
1489
+ ).toEqual(['InputFieldModel']);
1490
+ expect(
1491
+ registry
1492
+ .resolveFieldBindingCandidates('form-item-field', {
1493
+ interface: 'm2o',
1494
+ association: true,
1495
+ targetCollectionTemplate: 'tree',
1496
+ })
1497
+ .map((item) => item.use),
1498
+ ).toEqual(['CascadeSelectFieldModel', 'RecordSelectFieldModel']);
1499
+
1500
+ const bundle = registry.getSchemaBundle(['SchemaRegistryFieldHostModel', 'SchemaRegistryDisplayFieldHostModel']);
1501
+ const formItem = bundle.items.find((item) => item.use === 'SchemaRegistryFieldHostModel');
1502
+ const tableItem = bundle.items.find((item) => item.use === 'SchemaRegistryDisplayFieldHostModel');
1503
+
1504
+ expect(formItem?.subModelCatalog).toMatchObject({
1505
+ field: {
1506
+ type: 'object',
1507
+ candidates: expect.arrayContaining([
1508
+ expect.objectContaining({
1509
+ use: 'InputFieldModel',
1510
+ compatibility: expect.objectContaining({
1511
+ context: 'editable-field',
1512
+ interfaces: ['input'],
1513
+ isDefault: true,
1514
+ inheritParentFieldBinding: true,
1515
+ }),
1516
+ }),
1517
+ expect.objectContaining({
1518
+ use: 'RecordSelectFieldModel',
1519
+ compatibility: expect.objectContaining({
1520
+ context: 'editable-field',
1521
+ interfaces: ['m2o'],
1522
+ association: true,
1523
+ isDefault: true,
1524
+ }),
1525
+ }),
1526
+ ]),
1527
+ },
1528
+ });
1529
+ expect(tableItem?.subModelCatalog).toMatchObject({
1530
+ field: {
1531
+ type: 'object',
1532
+ candidates: [expect.objectContaining({ use: 'DisplayTextFieldModel' })],
1533
+ },
1534
+ });
1535
+
1536
+ const hostDoc = registry.getModelDocument('SchemaRegistryFieldHostModel');
1537
+ expect(hostDoc.dynamicHints).toEqual(
1538
+ expect.arrayContaining([
1539
+ expect.objectContaining({
1540
+ path: 'SchemaRegistryFieldHostModel.subModels.field',
1541
+ 'x-flow': expect.objectContaining({
1542
+ slotRules: expect.objectContaining({
1543
+ allowedUses: expect.arrayContaining([
1544
+ 'InputFieldModel',
1545
+ 'RecordSelectFieldModel',
1546
+ 'CascadeSelectFieldModel',
1547
+ ]),
1548
+ }),
1549
+ }),
1550
+ }),
1551
+ ]),
1552
+ );
1553
+ expect(JSON.stringify(bundle)).not.toContain('RuntimeFieldModel');
1554
+ });
1555
+
1556
+ it('should match wildcard field binding interfaces without breaking exact matches', () => {
1557
+ const registry = new FlowSchemaRegistry();
1558
+
1559
+ registry.registerFieldBindingContexts([{ name: 'editable-field' }]);
1560
+ registry.registerModelContribution(internalModelContribution('SchemaRegistryExactFieldModel', 'exact-field-uid'));
1561
+ registry.registerModelContribution(
1562
+ internalModelContribution('SchemaRegistryWildcardFieldModel', 'wildcard-field-uid'),
1563
+ );
1564
+
1565
+ registry.registerFieldBindings([
1566
+ {
1567
+ context: 'editable-field',
1568
+ use: 'SchemaRegistryExactFieldModel',
1569
+ interfaces: ['input'],
1570
+ isDefault: true,
1571
+ },
1572
+ {
1573
+ context: 'editable-field',
1574
+ use: 'SchemaRegistryWildcardFieldModel',
1575
+ interfaces: ['*'],
1576
+ },
1577
+ ]);
1578
+
1579
+ expect(
1580
+ registry.resolveFieldBindingCandidates('editable-field', { interface: 'uuid' }).map((item) => item.use),
1581
+ ).toEqual(['SchemaRegistryWildcardFieldModel']);
1582
+ expect(
1583
+ registry.resolveFieldBindingCandidates('editable-field', { interface: 'input' }).map((item) => item.use),
1584
+ ).toEqual(['SchemaRegistryExactFieldModel', 'SchemaRegistryWildcardFieldModel']);
1585
+ });
1586
+
1587
+ it('should project required and minItems slot constraints into schema documents and bundle catalogs', () => {
1588
+ const registry = new FlowSchemaRegistry();
1589
+
1590
+ registry.registerModelContribution(
1591
+ modelContribution('SchemaRegistryRequiredChildModel', {
1592
+ title: 'Required child',
1593
+ skeleton: {
1594
+ uid: 'required-child',
1595
+ use: 'SchemaRegistryRequiredChildModel',
1596
+ },
1597
+ }),
1598
+ );
1599
+
1600
+ registry.registerModelContribution(
1601
+ modelContribution('SchemaRegistryRequiredParentModel', {
1602
+ title: 'Required parent',
1603
+ subModelSlots: {
1604
+ page: objectSlot({
1605
+ use: 'SchemaRegistryRequiredChildModel',
1606
+ required: true,
1607
+ }),
1608
+ tabs: arraySlot({
1609
+ uses: ['SchemaRegistryRequiredChildModel'],
1610
+ required: true,
1611
+ minItems: 1,
1612
+ }),
1613
+ },
1614
+ skeleton: {
1615
+ uid: 'required-parent',
1616
+ use: 'SchemaRegistryRequiredParentModel',
1617
+ subModels: {
1618
+ page: {
1619
+ uid: 'required-parent-page',
1620
+ use: 'SchemaRegistryRequiredChildModel',
1621
+ },
1622
+ tabs: [
1623
+ {
1624
+ uid: 'required-parent-tab',
1625
+ use: 'SchemaRegistryRequiredChildModel',
1626
+ },
1627
+ ],
1628
+ },
1629
+ },
1630
+ }),
1631
+ );
1632
+
1633
+ const doc = registry.getModelDocument('SchemaRegistryRequiredParentModel');
1634
+ const bundle = registry.getSchemaBundle(['SchemaRegistryRequiredParentModel']);
1635
+ const bundleItem = bundle.items[0];
1636
+
1637
+ expect(doc.jsonSchema.properties?.subModels).toMatchObject({
1638
+ type: 'object',
1639
+ required: ['page', 'tabs'],
1640
+ properties: {
1641
+ page: {
1642
+ type: 'object',
1643
+ properties: {
1644
+ use: {
1645
+ const: 'SchemaRegistryRequiredChildModel',
1646
+ },
1647
+ },
1648
+ },
1649
+ tabs: {
1650
+ type: 'array',
1651
+ minItems: 1,
1652
+ },
1653
+ },
1654
+ });
1655
+ expect(bundleItem?.subModelCatalog).toMatchObject({
1656
+ page: {
1657
+ type: 'object',
1658
+ required: true,
1659
+ candidates: [expect.objectContaining({ use: 'SchemaRegistryRequiredChildModel' })],
1660
+ },
1661
+ tabs: {
1662
+ type: 'array',
1663
+ required: true,
1664
+ minItems: 1,
1665
+ candidates: [expect.objectContaining({ use: 'SchemaRegistryRequiredChildModel' })],
1666
+ },
1667
+ });
1668
+ });
1669
+
1670
+ it('should expose nested popup page slots in schema documents and bundle catalogs', () => {
1671
+ const registry = new FlowSchemaRegistry();
1672
+
1673
+ registry.registerModelContribution(
1674
+ modelContribution('SchemaRegistryPopupGridModel', {
1675
+ skeleton: {
1676
+ uid: 'popup-grid',
1677
+ use: 'SchemaRegistryPopupGridModel',
1678
+ },
1679
+ }),
1680
+ );
1681
+
1682
+ registry.registerModelContribution(
1683
+ modelContribution('SchemaRegistryPopupTabModel', {
1684
+ subModelSlots: {
1685
+ grid: objectSlot({
1686
+ use: 'SchemaRegistryPopupGridModel',
1687
+ required: true,
1688
+ }),
1689
+ },
1690
+ skeleton: {
1691
+ uid: 'popup-tab',
1692
+ use: 'SchemaRegistryPopupTabModel',
1693
+ subModels: {
1694
+ grid: {
1695
+ uid: 'popup-tab-grid',
1696
+ use: 'SchemaRegistryPopupGridModel',
1697
+ },
1698
+ },
1699
+ },
1700
+ }),
1701
+ );
1702
+
1703
+ registry.registerModelContribution(
1704
+ modelContribution('SchemaRegistryPopupPageModel', {
1705
+ subModelSlots: {
1706
+ tabs: arraySlot({
1707
+ uses: ['SchemaRegistryPopupTabModel'],
1708
+ required: true,
1709
+ minItems: 1,
1710
+ }),
1711
+ },
1712
+ skeleton: {
1713
+ uid: 'popup-page',
1714
+ use: 'SchemaRegistryPopupPageModel',
1715
+ subModels: {
1716
+ tabs: [
1717
+ {
1718
+ uid: 'popup-page-tab',
1719
+ use: 'SchemaRegistryPopupTabModel',
1720
+ subModels: {
1721
+ grid: {
1722
+ uid: 'popup-page-tab-grid',
1723
+ use: 'SchemaRegistryPopupGridModel',
1724
+ },
1725
+ },
1726
+ },
1727
+ ],
1728
+ },
1729
+ },
1730
+ }),
1731
+ );
1732
+
1733
+ registry.registerModelContribution(
1734
+ modelContribution('SchemaRegistryPopupActionModel', {
1735
+ stepParamsSchema: objectSchema({
1736
+ popupSettings: objectSchema(
1737
+ {
1738
+ openView: objectSchema(
1739
+ {
1740
+ pageModelClass: {
1741
+ type: 'string',
1742
+ enum: ['SchemaRegistryPopupPageModel', 'SchemaRegistryRootPageModel'],
1743
+ },
1744
+ },
1745
+ { additionalProperties: false },
1746
+ ),
1747
+ },
1748
+ { additionalProperties: true },
1749
+ ),
1750
+ }),
1751
+ subModelSlots: {
1752
+ page: objectSlot({
1753
+ use: 'SchemaRegistryPopupPageModel',
1754
+ }),
1755
+ },
1756
+ skeleton: {
1757
+ uid: 'popup-action',
1758
+ use: 'SchemaRegistryPopupActionModel',
1759
+ stepParams: {
1760
+ popupSettings: {
1761
+ openView: {
1762
+ pageModelClass: 'SchemaRegistryPopupPageModel',
1763
+ },
1764
+ },
1765
+ },
1766
+ subModels: {
1767
+ page: {
1768
+ uid: 'popup-action-page',
1769
+ use: 'SchemaRegistryPopupPageModel',
1770
+ subModels: {
1771
+ tabs: [
1772
+ {
1773
+ uid: 'popup-action-page-tab',
1774
+ use: 'SchemaRegistryPopupTabModel',
1775
+ subModels: {
1776
+ grid: {
1777
+ uid: 'popup-action-page-grid',
1778
+ use: 'SchemaRegistryPopupGridModel',
1779
+ },
1780
+ },
1781
+ },
1782
+ ],
1783
+ },
1784
+ },
1785
+ },
1786
+ },
1787
+ }),
1788
+ );
1789
+
1790
+ const doc = registry.getModelDocument('SchemaRegistryPopupActionModel');
1791
+ const bundle = registry.getSchemaBundle(['SchemaRegistryPopupActionModel']);
1792
+ const item = bundle.items[0];
1793
+
1794
+ expect((doc.jsonSchema.properties?.subModels as any)?.properties?.page).toMatchObject({
1795
+ type: 'object',
1796
+ properties: {
1797
+ use: {
1798
+ const: 'SchemaRegistryPopupPageModel',
1799
+ },
1800
+ },
1801
+ });
1802
+ expect(
1803
+ (doc.jsonSchema.properties?.stepParams as any)?.properties?.popupSettings?.properties?.openView,
1804
+ ).toMatchObject({
1805
+ type: 'object',
1806
+ properties: {
1807
+ pageModelClass: {
1808
+ type: 'string',
1809
+ enum: ['SchemaRegistryPopupPageModel', 'SchemaRegistryRootPageModel'],
1810
+ },
1811
+ },
1812
+ });
1813
+ expect(doc.minimalExample).toMatchObject({
1814
+ use: 'SchemaRegistryPopupActionModel',
1815
+ subModels: {
1816
+ page: {
1817
+ use: 'SchemaRegistryPopupPageModel',
1818
+ },
1819
+ },
1820
+ stepParams: {
1821
+ popupSettings: {
1822
+ openView: {
1823
+ pageModelClass: 'SchemaRegistryPopupPageModel',
1824
+ },
1825
+ },
1826
+ },
1827
+ });
1828
+ expect(item?.subModelCatalog).toMatchObject({
1829
+ page: {
1830
+ type: 'object',
1831
+ candidates: [
1832
+ expect.objectContaining({
1833
+ use: 'SchemaRegistryPopupPageModel',
1834
+ subModelCatalog: {
1835
+ tabs: {
1836
+ type: 'array',
1837
+ required: true,
1838
+ minItems: 1,
1839
+ candidates: [
1840
+ expect.objectContaining({
1841
+ use: 'SchemaRegistryPopupTabModel',
1842
+ subModelCatalog: {
1843
+ grid: {
1844
+ type: 'object',
1845
+ required: true,
1846
+ candidates: [expect.objectContaining({ use: 'SchemaRegistryPopupGridModel' })],
1847
+ },
1848
+ },
1849
+ }),
1850
+ ],
1851
+ },
1852
+ },
1853
+ }),
1854
+ ],
1855
+ },
1856
+ });
1857
+ });
1858
+
1859
+ it('should expose compact public documents without recursive schema or nested hints by default', () => {
1860
+ const registry = new FlowSchemaRegistry();
1861
+
1862
+ registry.registerModelContribution(
1863
+ modelContribution('SchemaRegistryCompactLeafModel', {
1864
+ stepParamsSchema: objectSchema(
1865
+ {
1866
+ leaf: { type: 'string' },
1867
+ },
1868
+ { additionalProperties: false },
1869
+ ),
1870
+ dynamicHints: [
1871
+ {
1872
+ kind: 'dynamic-ui-schema',
1873
+ path: 'SchemaRegistryCompactLeafModel.stepParams.leaf',
1874
+ message: 'Leaf settings are dynamic.',
1875
+ },
1876
+ ],
1877
+ }),
1878
+ );
1879
+
1880
+ registry.registerModelContribution(
1881
+ modelContribution('SchemaRegistryCompactChildModel', {
1882
+ stepParamsSchema: objectSchema(
1883
+ {
1884
+ child: { type: 'string' },
1885
+ },
1886
+ { additionalProperties: false },
1887
+ ),
1888
+ dynamicHints: [
1889
+ {
1890
+ kind: 'dynamic-ui-schema',
1891
+ path: 'SchemaRegistryCompactChildModel.stepParams.child',
1892
+ message: 'Child settings are dynamic.',
1893
+ },
1894
+ ],
1895
+ subModelSlots: {
1896
+ footer: objectSlot({
1897
+ use: 'SchemaRegistryCompactLeafModel',
1898
+ }),
1899
+ },
1900
+ }),
1901
+ );
1902
+
1903
+ registry.registerModelContribution(
1904
+ modelContribution('SchemaRegistryCompactParentModel', {
1905
+ stepParamsSchema: objectSchema(
1906
+ {
1907
+ enabled: { type: 'boolean' },
1908
+ },
1909
+ { additionalProperties: false },
1910
+ ),
1911
+ subModelSlots: {
1912
+ body: objectSlot({
1913
+ use: 'SchemaRegistryCompactChildModel',
1914
+ required: true,
1915
+ }),
1916
+ },
1917
+ }),
1918
+ );
1919
+
1920
+ const compact = registry.getPublicModelDocument('SchemaRegistryCompactParentModel');
1921
+ const full = registry.getPublicModelDocument('SchemaRegistryCompactParentModel', { detail: 'full' });
1922
+
1923
+ expect(compact).not.toHaveProperty('coverage');
1924
+ expect(compact).not.toHaveProperty('skeleton');
1925
+ expect(compact).not.toHaveProperty('examples');
1926
+ expect(compact.dynamicHints).toEqual(
1927
+ expect.arrayContaining([
1928
+ expect.objectContaining({
1929
+ path: 'SchemaRegistryCompactParentModel.subModels.body',
1930
+ }),
1931
+ ]),
1932
+ );
1933
+ expect(JSON.stringify(compact.dynamicHints)).not.toContain('SchemaRegistryCompactChildModel.stepParams.child');
1934
+ expect(
1935
+ (compact.jsonSchema.properties?.subModels as any)?.properties?.body?.properties?.subModels?.properties?.footer,
1936
+ ).toBeUndefined();
1937
+ expect((full.dynamicHints || []).length).toBeGreaterThan(compact.dynamicHints.length);
1938
+ expect(
1939
+ (full.jsonSchema.properties?.subModels as any)?.properties?.body?.properties?.subModels?.properties,
1940
+ ).toMatchObject({
1941
+ footer: {
1942
+ type: 'object',
1943
+ properties: {
1944
+ use: {
1945
+ const: 'SchemaRegistryCompactLeafModel',
1946
+ },
1947
+ },
1948
+ },
1949
+ });
1950
+ });
1951
+ });