@reventlessdev/reventless-core 3.0.0-alpha.265 → 3.0.0-alpha.267

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 (41) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +5 -5
  3. package/src/Projection.res +0 -10
  4. package/src/Projection.res.mjs +0 -14
  5. package/src/ProjectionMapper.res +8 -2
  6. package/src/ProjectionMapper.res.mjs +3 -3
  7. package/src/admin/Platform_Admin_Structure.res.mjs +1 -1
  8. package/src/admin/UiFragmentRegistry/StateView/UiFragments.res.mjs +3 -0
  9. package/src/components/Api/GraphQL_FragmentGenerator.res +47 -16
  10. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +20 -7
  11. package/src/components/Api/SchemaType.res +13 -3
  12. package/src/components/Api/SchemaType.res.mjs +13 -2
  13. package/src/components/Api/SuryToJsonSchema.res +9 -5
  14. package/src/components/Api/SuryToJsonSchema.res.mjs +14 -2
  15. package/src/components/EventMapper/EventMapper_Builder.res +19 -1
  16. package/src/components/EventMapper/EventMapper_Builder.res.mjs +10 -1
  17. package/src/components/StateViewSlice/StateViewSlice_Builder.res +11 -1
  18. package/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs +2 -1
  19. package/src/components/StateViewSlice/StateViewSlice_Callback.res +8 -1
  20. package/src/components/StateViewSlice/StateViewSlice_Callback.res.mjs +2 -1
  21. package/src/plugin/component/Plugin_Structure.res +148 -25
  22. package/src/plugin/component/Plugin_Structure.res.mjs +114 -30
  23. package/tests/api/GraphQL_FragmentGeneratorTest.res +76 -1
  24. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +78 -17
  25. package/tests/api/IdentitySemanticTest.res +77 -0
  26. package/tests/api/IdentitySemanticTest.res.mjs +71 -0
  27. package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +0 -1
  28. package/tests/dcb/DcbStateChangeSliceTest.res.mjs +0 -3
  29. package/tests/eventmapper/EventMapperFixtures.res +2 -2
  30. package/tests/eventmapper/EventMapperFixtures.res.mjs +14 -2
  31. package/tests/plugin/IdentityReferenceTest.res +99 -0
  32. package/tests/plugin/IdentityReferenceTest.res.mjs +169 -0
  33. package/tests/plugin/PluginStructureAccessTest.res.mjs +14 -1
  34. package/tests/plugin/PluginStructureTest.res.mjs +42 -10
  35. package/tests/plugin/StateView/PsAnnotatedView.res.mjs +3 -0
  36. package/tests/plugin/StateView/PsAvailableProductsView.res.mjs +3 -0
  37. package/tests/plugin/StateView/PsCategoriesView.res.mjs +3 -0
  38. package/tests/plugin/StateView/PsCustomersView.res.mjs +3 -0
  39. package/tests/plugin/StateView/PsGatedView.res.mjs +3 -0
  40. package/tests/plugin/StateView/PsOrdersView.res.mjs +3 -0
  41. package/tests/plugin/StateView/PsShipmentsView.res.mjs +3 -0
@@ -587,31 +587,92 @@ let labelFieldsFromStateSchema = (
587
587
  // Mirrors the command walk for emitted events. Module-level so Platform_Admin,
588
588
  // whose components never pass through `make`, derives its defs the same way.
589
589
 
590
+ /**
591
+ The plugin's views by the identity each is keyed by, which is what lets a typed
592
+ id find the view that lists it without an `@ref`. `report` says where the type
593
+ cannot decide: several views keyed by the identity, none, or an `@ref` naming a
594
+ view keyed by another.
595
+ */
596
+ type identityViews = {
597
+ viewsByKey: dict<array<string>>,
598
+ viewNames: array<string>,
599
+ report: string => unit,
600
+ }
601
+
602
+ // The reference a typed id implies, and the check on one it declares.
603
+ let identityReference = (
604
+ identityViews: identityViews,
605
+ ~fieldName,
606
+ fieldSchema: S.t<unknown>,
607
+ declared: array<(string, Reventless.Reference.target)>,
608
+ ): array<(string, Reventless.Reference.target)> =>
609
+ switch Reventless.Semantic.fieldIdentityKey(fieldSchema) {
610
+ | None => declared
611
+ | Some(key) =>
612
+ let keyed = identityViews.viewsByKey->Dict.get(key)->Option.getOr([])
613
+ switch declared {
614
+ | [] =>
615
+ switch keyed {
616
+ | [view] => [(fieldName, {Reventless.Semantic.entity: view, plugin: None, identity: key})]
617
+ | [] =>
618
+ identityViews.report(
619
+ `${fieldName} is a ${key}, and no view in this plugin is keyed by it, so it references nothing. List the identity in a view, or reference another plugin's with @ref("Plugin.View").`,
620
+ )
621
+ []
622
+ | several =>
623
+ identityViews.report(
624
+ `${fieldName} is a ${key}, and ${several->Array.join(
625
+ " and ",
626
+ )} are all keyed by it. Say which it references with @ref.`,
627
+ )
628
+ []
629
+ }
630
+ | _ =>
631
+ declared->Array.forEach(((_, target)) =>
632
+ if (
633
+ target.plugin->Option.isNone &&
634
+ identityViews.viewNames->Array.includes(target.entity) &&
635
+ !(keyed->Array.includes(target.entity))
636
+ ) {
637
+ identityViews.report(
638
+ `${fieldName} is a ${key}, and its @ref names ${target.entity}, which is not keyed by it.`,
639
+ )
640
+ }
641
+ )
642
+ declared
643
+ }
644
+ }
645
+
590
646
  // A variant's cross-entity references, shared by the command and event walks.
591
647
  // `getFieldTarget`, not `getTarget`: an `array<string>` field declares it on the
592
648
  // element schema.
593
- let extractReferences = (properties: dict<S.t<unknown>>): array<Reventless.Plugin.fieldReference> =>
649
+ let extractReferences = (
650
+ ~identityViews: option<identityViews>=?,
651
+ properties: dict<S.t<unknown>>,
652
+ ): array<Reventless.Plugin.fieldReference> =>
594
653
  properties
595
654
  ->Dict.toArray
596
- ->Array.flatMap(((fieldName, fieldSchema)) =>
655
+ ->Array.flatMap(((fieldName, fieldSchema)) => {
597
656
  // `collectFieldTargets`, not `getFieldTarget`: a reference declared on a field
598
657
  // of a record the field holds (an order line's `productId`) is one this
599
658
  // command declares, and it arrives named by its own path. Commands and events
600
659
  // both come through here, which is what stops one of them learning about
601
660
  // nesting and the other not.
602
- Reventless.Reference.collectFieldTargets(fieldName, fieldSchema)->Array.map(((
603
- path,
604
- target,
605
- )): Reventless.Plugin.fieldReference => {
661
+ let declared = Reventless.Reference.collectFieldTargets(fieldName, fieldSchema)
662
+ let targets = switch identityViews {
663
+ | Some(views) => identityReference(views, ~fieldName, fieldSchema, declared)
664
+ | None => declared
665
+ }
666
+ targets->Array.map(((path, target)): Reventless.Plugin.fieldReference => {
606
667
  Reventless.Plugin.fieldName: path,
607
668
  entity: target.entity,
608
669
  plugin: target.plugin,
609
670
  })
610
- )
671
+ })
611
672
 
612
- let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
673
+ let toEventDef = (~identityViews=?, v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
613
674
  let mkDef = (~variantName, ~properties) => {
614
- let references = extractReferences(properties)
675
+ let references = extractReferences(~identityViews?, properties)
615
676
 
616
677
  (
617
678
  {
@@ -640,10 +701,12 @@ let toEventDef = (v: S.t<unknown>): option<Reventless.Plugin.eventDef> => {
640
701
  }
641
702
  }
642
703
 
643
- let extractEventDefs = (eventSchema: S.t<unknown>): array<Reventless.Plugin.eventDef> =>
704
+ let extractEventDefs = (~identityViews=?, eventSchema: S.t<unknown>): array<
705
+ Reventless.Plugin.eventDef,
706
+ > =>
644
707
  switch eventSchema {
645
- | AnyOf({anyOf}) => anyOf->Array.filterMap(toEventDef)
646
- | _ => toEventDef(eventSchema)->Option.mapOr([], def => [def])
708
+ | AnyOf({anyOf}) => anyOf->Array.filterMap(v => toEventDef(~identityViews?, v))
709
+ | _ => toEventDef(~identityViews?, eventSchema)->Option.mapOr([], def => [def])
647
710
  }
648
711
 
649
712
  // Errors walk identically to events; only the def type differs, so a consumer
@@ -750,6 +813,7 @@ let annotateArgTypes = (schema: JSON.t, argTypes: dict<string>): JSON.t => {
750
813
  }
751
814
 
752
815
  let toCommandDef = (
816
+ ~identityViews: option<identityViews>=?,
753
817
  ~isAggregate,
754
818
  ~partitionKey: option<string>=?,
755
819
  ~mutationFieldFor: string => string,
@@ -785,7 +849,7 @@ let toCommandDef = (
785
849
  ~variantName,
786
850
  properties,
787
851
  )
788
- let references = extractReferences(properties)
852
+ let references = extractReferences(~identityViews?, properties)
789
853
  // Evaluated against a synthetic value per constructor, the same shape the
790
854
  // resolver builds at call time: a payload-bearing variant compiles to
791
855
  // `{TAG, ...}`, a payload-less one to a bare string.
@@ -923,6 +987,7 @@ let toCommandDef = (
923
987
  }
924
988
 
925
989
  let extractCommandDefs = (
990
+ ~identityViews: option<identityViews>=?,
926
991
  ~isAggregate,
927
992
  ~partitionKey: option<string>=?,
928
993
  ~mutationFieldFor: string => string,
@@ -935,6 +1000,7 @@ let extractCommandDefs = (
935
1000
  | AnyOf({anyOf}) =>
936
1001
  anyOf->Array.filterMap(v =>
937
1002
  toCommandDef(
1003
+ ~identityViews?,
938
1004
  ~isAggregate,
939
1005
  ~partitionKey?,
940
1006
  ~mutationFieldFor,
@@ -948,6 +1014,7 @@ let extractCommandDefs = (
948
1014
  | _ =>
949
1015
  // Single-variant command types compile to a bare Object schema, not a Union.
950
1016
  toCommandDef(
1017
+ ~identityViews?,
951
1018
  ~isAggregate,
952
1019
  ~partitionKey?,
953
1020
  ~mutationFieldFor,
@@ -986,9 +1053,12 @@ let queryableDefFromSpec = (
986
1053
  ): Reventless.Plugin.queryableDef => {
987
1054
  let qf = Api_Naming.queryFieldNamesForReadModel(~plugin, ~name)
988
1055
  let label = labelFieldsFromStateSchema(~entityName=name, stateSchema)
989
- // The same call the capability deriver makes, so the published key and the key
990
- // the generated filter/order-by is built from cannot disagree.
991
- let keyField = GraphQL_FragmentGenerator.resolveKeyField(~entityName=name, stateSchema)
1056
+ // The published key is always filterable; not every filterable field is the key.
1057
+ let keyField = GraphQL_FragmentGenerator.resolveKeyField(
1058
+ ~rowKeying=RowKeyedByEnvelope,
1059
+ ~entityName=name,
1060
+ stateSchema,
1061
+ )
992
1062
  {
993
1063
  Reventless.Plugin.name,
994
1064
  queryField: qf.listFieldName,
@@ -1414,8 +1484,8 @@ let make = (
1414
1484
 
1415
1485
  // Warned rather than refused: an unkeyed view still answers, just without a
1416
1486
  // filter or an ordering, and a lint that stops a deploy gets silenced.
1417
- let recordKeyField = (~entityName, stateSchema) =>
1418
- GraphQL_FragmentGenerator.classifyKeyField(~entityName, stateSchema)
1487
+ let recordKeyField = (~rowKeying, ~entityName, stateSchema) =>
1488
+ GraphQL_FragmentGenerator.classifyKeyField(~rowKeying, ~entityName, stateSchema)
1419
1489
  ->GraphQL_FragmentGenerator.keyFieldGapMessage
1420
1490
  ->Option.forEach(why => log.warn(~comp="Plugin_Structure", `${name}/${entityName} ${why}`))
1421
1491
 
@@ -1425,14 +1495,17 @@ let make = (
1425
1495
  let qf = Api_Naming.queryFieldNamesForReadModel(~plugin=name, ~name=R.Spec.name)
1426
1496
  let stateSchema = R.Spec.stateSchema->S.castToUnknown
1427
1497
  let label = labelFieldsFromStateSchema(~entityName=R.Spec.name, stateSchema)
1428
- // The same call the capability deriver makes, so the published key and the
1429
- // key the generated filter/order-by is built from cannot disagree.
1430
- let keyField = GraphQL_FragmentGenerator.resolveKeyField(~entityName=R.Spec.name, stateSchema)
1498
+ // The published key is always filterable; not every filterable field is the key.
1499
+ let keyField = GraphQL_FragmentGenerator.resolveKeyField(
1500
+ ~rowKeying=RowKeyedByEnvelope,
1501
+ ~entityName=R.Spec.name,
1502
+ stateSchema,
1503
+ )
1431
1504
  // Qualified to the plugin's event ids so they match the producers' nodes.
1432
1505
  let consumed = qualify(~prefix=name, R.consumedEventNames)
1433
1506
  recordRetired(~entityName=R.Spec.name, stateSchema)
1434
1507
  recordLifecycle(~entityName=R.Spec.name, stateSchema)
1435
- recordKeyField(~entityName=R.Spec.name, stateSchema)
1508
+ recordKeyField(~rowKeying=RowKeyedByEnvelope, ~entityName=R.Spec.name, stateSchema)
1436
1509
 
1437
1510
  (
1438
1511
  {
@@ -1471,12 +1544,13 @@ let make = (
1471
1544
  let stateSchema = SVS.Spec.stateSchema->S.castToUnknown
1472
1545
  let label = labelFieldsFromStateSchema(~entityName=SVS.Spec.name, stateSchema)
1473
1546
  let keyField = GraphQL_FragmentGenerator.resolveKeyField(
1547
+ ~rowKeying=RowKeyedByStateField,
1474
1548
  ~entityName=SVS.Spec.name,
1475
1549
  stateSchema,
1476
1550
  )
1477
1551
  recordRetired(~entityName=SVS.Spec.name, stateSchema)
1478
1552
  recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
1479
- recordKeyField(~entityName=SVS.Spec.name, stateSchema)
1553
+ recordKeyField(~rowKeying=RowKeyedByStateField, ~entityName=SVS.Spec.name, stateSchema)
1480
1554
 
1481
1555
  (
1482
1556
  {
@@ -1529,6 +1603,53 @@ let make = (
1529
1603
  }
1530
1604
  let partitionFailures = []
1531
1605
 
1606
+ // Each view's key as an identity: a read model's `Spec.Id` when it is one, else
1607
+ // the key field's type, else the key field's name, so a view keyed by an untyped
1608
+ // `productId` still lists the `productId` identity.
1609
+ let keyIdentityOf = (stateSchema: S.t<unknown>, idField: option<string>) =>
1610
+ idField->Option.map(field =>
1611
+ switch stateSchema {
1612
+ | Object({properties}) =>
1613
+ properties->Dict.get(field)->Option.flatMap(Reventless.Semantic.identityKey)
1614
+ | _ => None
1615
+ }->Option.getOr(field)
1616
+ )
1617
+ let viewKeys = Array.concat(
1618
+ readModels->Array.mapWithIndex((
1619
+ module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
1620
+ i,
1621
+ ) => {
1622
+ let def = readModelDefs->Array.getUnsafe(i)
1623
+ let key = switch R.Spec.Id.schema->Reventless.Semantic.identityKey {
1624
+ | Some(_) as declared => declared
1625
+ | None => keyIdentityOf(R.Spec.stateSchema->S.castToUnknown, def.idField)
1626
+ }
1627
+ (R.Spec.name, key)
1628
+ }),
1629
+ stateViewSlices->Array.mapWithIndex((module(SVS: ReventlessInfra.StateViewSlice.T), i) => {
1630
+ let def = stateViewDefs->Array.getUnsafe(i)
1631
+ (SVS.Spec.name, keyIdentityOf(SVS.Spec.stateSchema->S.castToUnknown, def.idField))
1632
+ }),
1633
+ )
1634
+ let viewsByKey: dict<array<string>> = Dict.make()
1635
+ viewKeys->Array.forEach(((view, key)) =>
1636
+ key->Option.forEach(k =>
1637
+ viewsByKey->Dict.set(k, viewsByKey->Dict.get(k)->Option.getOr([])->Array.concat([view]))
1638
+ )
1639
+ )
1640
+ // Warned rather than refused, like the key-field gap: the field still decodes,
1641
+ // it just links nowhere. Once per message, since a field recurs across variants.
1642
+ let reported: Set.t<string> = Set.make()
1643
+ let identityViews = {
1644
+ viewsByKey,
1645
+ viewNames: viewKeys->Array.map(((view, _)) => view),
1646
+ report: message =>
1647
+ if !(reported->Set.has(message)) {
1648
+ reported->Set.add(message)
1649
+ log.warn(~comp="Plugin_Structure", `${name}: ${message}`)
1650
+ },
1651
+ }
1652
+
1532
1653
  let stateChangeDefs =
1533
1654
  stateChangeSlices->Array.mapWithIndex((module(SCS: ReventlessInfra.StateChangeSlice.T), i) => {
1534
1655
  let (_, produced) = scsProduced->Array.getUnsafe(i)
@@ -1538,6 +1659,7 @@ let make = (
1538
1659
  {
1539
1660
  Reventless.Plugin.name: SCS.Spec.name,
1540
1661
  commands: extractCommandDefs(
1662
+ ~identityViews,
1541
1663
  ~isAggregate=false,
1542
1664
  ~partitionKey=?partitionBySlice->Dict.get(SCS.Spec.name),
1543
1665
  ~mutationFieldFor=variantName =>
@@ -1556,7 +1678,7 @@ let make = (
1556
1678
  consumedEventTypes: consumed,
1557
1679
  linkedViews: linkedSvsFor(produced),
1558
1680
  consistencyRead: consistencyReadFor(consumed),
1559
- events: extractEventDefs(SCS.Spec.eventSchema->S.castToUnknown),
1681
+ events: extractEventDefs(~identityViews, SCS.Spec.eventSchema->S.castToUnknown),
1560
1682
  errors: extractErrorDefs(SCS.Spec.errorSchema->S.castToUnknown),
1561
1683
  chapter: chapterOf(SCS.Spec.name),
1562
1684
  }: Reventless.Plugin.writableDef
@@ -1573,6 +1695,7 @@ let make = (
1573
1695
  {
1574
1696
  Reventless.Plugin.name: A.Spec.name,
1575
1697
  commands: extractCommandDefs(
1698
+ ~identityViews,
1576
1699
  ~isAggregate=true,
1577
1700
  ~mutationFieldFor=variantName =>
1578
1701
  Api_Naming.aggregateMutationField(
@@ -1589,7 +1712,7 @@ let make = (
1589
1712
  consumedEventTypes: [],
1590
1713
  linkedViews: Array.concat(linkedSvsFor(produced), linkedReadModelsFor(A.Spec.name)),
1591
1714
  consistencyRead: None,
1592
- events: extractEventDefs(A.Spec.eventSchema->S.castToUnknown),
1715
+ events: extractEventDefs(~identityViews, A.Spec.eventSchema->S.castToUnknown),
1593
1716
  errors: extractErrorDefs(A.Spec.errorSchema->S.castToUnknown),
1594
1717
  chapter: chapterOf(A.Spec.name),
1595
1718
  }: Reventless.Plugin.writableDef
@@ -12,6 +12,7 @@ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js
12
12
  import * as Trait$Reventless from "@reventlessdev/reventless-spec/src/types/Trait.res.mjs";
13
13
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
14
14
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
15
+ import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
15
16
  import * as Lifecycle$Reventless from "@reventlessdev/reventless-spec/src/types/Lifecycle.res.mjs";
16
17
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
17
18
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
@@ -444,20 +445,62 @@ function labelFieldsFromStateSchema(entityName, stateSchema) {
444
445
  };
445
446
  }
446
447
 
447
- function extractReferences(properties) {
448
- return Object.entries(properties).flatMap(param => Reference$Reventless.collectFieldTargets(param[0], param[1]).map(param => {
449
- let target = param[1];
450
- return {
451
- fieldName: param[0],
452
- entity: target.entity,
453
- plugin: target.plugin
454
- };
455
- }));
448
+ function identityReference(identityViews, fieldName, fieldSchema, declared) {
449
+ let key = Semantic$Reventless.fieldIdentityKey(fieldSchema);
450
+ if (key === undefined) {
451
+ return declared;
452
+ }
453
+ let keyed = Stdlib_Option.getOr(identityViews.viewsByKey[key], []);
454
+ if (declared.length !== 0) {
455
+ declared.forEach(param => {
456
+ let target = param[1];
457
+ if (Stdlib_Option.isNone(target.plugin) && identityViews.viewNames.includes(target.entity) && !keyed.includes(target.entity)) {
458
+ return identityViews.report(fieldName + ` is a ` + key + `, and its @ref names ` + target.entity + `, which is not keyed by it.`);
459
+ }
460
+ });
461
+ return declared;
462
+ }
463
+ let len = keyed.length;
464
+ if (len !== 1) {
465
+ if (len !== 0) {
466
+ identityViews.report(fieldName + ` is a ` + key + `, and ` + keyed.join(" and ") + ` are all keyed by it. Say which it references with @ref.`);
467
+ return [];
468
+ } else {
469
+ identityViews.report(fieldName + ` is a ` + key + `, and no view in this plugin is keyed by it, so it references nothing. List the identity in a view, or reference another plugin's with @ref("Plugin.View").`);
470
+ return [];
471
+ }
472
+ }
473
+ let view = keyed[0];
474
+ return [[
475
+ fieldName,
476
+ {
477
+ entity: view,
478
+ plugin: undefined,
479
+ identity: key
480
+ }
481
+ ]];
456
482
  }
457
483
 
458
- function toEventDef(v) {
484
+ function extractReferences(identityViews, properties) {
485
+ return Object.entries(properties).flatMap(param => {
486
+ let fieldSchema = param[1];
487
+ let fieldName = param[0];
488
+ let declared = Reference$Reventless.collectFieldTargets(fieldName, fieldSchema);
489
+ let targets = identityViews !== undefined ? identityReference(identityViews, fieldName, fieldSchema, declared) : declared;
490
+ return targets.map(param => {
491
+ let target = param[1];
492
+ return {
493
+ fieldName: param[0],
494
+ entity: target.entity,
495
+ plugin: target.plugin
496
+ };
497
+ });
498
+ });
499
+ }
500
+
501
+ function toEventDef(identityViews, v) {
459
502
  let mkDef = (variantName, properties) => {
460
- let references = extractReferences(properties);
503
+ let references = extractReferences(identityViews, properties);
461
504
  return {
462
505
  name: variantName,
463
506
  schema: JSON.stringify(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(undefined, undefined, v)),
@@ -488,16 +531,16 @@ function toEventDef(v) {
488
531
  }
489
532
  }
490
533
 
491
- function extractEventDefs(eventSchema) {
534
+ function extractEventDefs(identityViews, eventSchema) {
492
535
  if (eventSchema.type === "anyOf") {
493
- return Stdlib_Array.filterMap(eventSchema.anyOf, toEventDef);
536
+ return Stdlib_Array.filterMap(eventSchema.anyOf, v => toEventDef(identityViews, v));
494
537
  } else {
495
- return Stdlib_Option.mapOr(toEventDef(eventSchema), [], def => [def]);
538
+ return Stdlib_Option.mapOr(toEventDef(identityViews, eventSchema), [], def => [def]);
496
539
  }
497
540
  }
498
541
 
499
542
  function extractErrorDefs(errorSchema) {
500
- return extractEventDefs(errorSchema).map(param => ({
543
+ return extractEventDefs(undefined, errorSchema).map(param => ({
501
544
  name: param.name,
502
545
  schema: param.schema,
503
546
  references: param.references
@@ -597,10 +640,10 @@ function annotateArgTypes(schema, argTypes) {
597
640
  return schema;
598
641
  }
599
642
 
600
- function toCommandDef(isAggregate, partitionKey, mutationFieldFor, parentSchema, commandAuthorization, commandTransition, derivedEdgeFor, v) {
643
+ function toCommandDef(identityViews, isAggregate, partitionKey, mutationFieldFor, parentSchema, commandAuthorization, commandTransition, derivedEdgeFor, v) {
601
644
  let mkDef = (variantName, properties) => {
602
645
  let match = commandLevelAndId(isAggregate, partitionKey, variantName, properties);
603
- let references = extractReferences(properties);
646
+ let references = extractReferences(identityViews, properties);
604
647
  let syntheticCommand = DcbTag$Reventless.isVariantPayloadBearing(parentSchema, variantName) ? ({
605
648
  TAG: variantName
606
649
  }) : variantName;
@@ -683,12 +726,12 @@ function toCommandDef(isAggregate, partitionKey, mutationFieldFor, parentSchema,
683
726
  }
684
727
  }
685
728
 
686
- function extractCommandDefs(isAggregate, partitionKey, mutationFieldFor, commandAuthorization, commandTransition, derivedEdgeForOpt, commandSchema) {
729
+ function extractCommandDefs(identityViews, isAggregate, partitionKey, mutationFieldFor, commandAuthorization, commandTransition, derivedEdgeForOpt, commandSchema) {
687
730
  let derivedEdgeFor = derivedEdgeForOpt !== undefined ? derivedEdgeForOpt : param => {};
688
731
  if (commandSchema.type === "anyOf") {
689
- return Stdlib_Array.filterMap(commandSchema.anyOf, v => toCommandDef(isAggregate, partitionKey, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, v));
732
+ return Stdlib_Array.filterMap(commandSchema.anyOf, v => toCommandDef(identityViews, isAggregate, partitionKey, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, v));
690
733
  } else {
691
- return Stdlib_Option.mapOr(toCommandDef(isAggregate, partitionKey, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, commandSchema), [], def => [def]);
734
+ return Stdlib_Option.mapOr(toCommandDef(identityViews, isAggregate, partitionKey, mutationFieldFor, commandSchema, commandAuthorization, commandTransition, derivedEdgeFor, commandSchema), [], def => [def]);
692
735
  }
693
736
  }
694
737
 
@@ -706,7 +749,7 @@ function queryableDefFromSpec(plugin, name, stateSchema, authorization, visibili
706
749
  let linkedWriteSide = linkedWriteSideOpt !== undefined ? linkedWriteSideOpt : [];
707
750
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(plugin, name, undefined);
708
751
  let label = labelFieldsFromStateSchema(name, stateSchema);
709
- let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(name, stateSchema);
752
+ let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", name, stateSchema);
710
753
  return {
711
754
  name: name,
712
755
  queryField: qf.listFieldName,
@@ -1006,16 +1049,16 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1006
1049
  retiredFailures.push(f);
1007
1050
  });
1008
1051
  };
1009
- let recordKeyField = (entityName, stateSchema) => Stdlib_Option.forEach(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField(entityName, stateSchema)), why => log.warn("Plugin_Structure", undefined, name + `/` + entityName + ` ` + why));
1052
+ let recordKeyField = (rowKeying, entityName, stateSchema) => Stdlib_Option.forEach(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField(rowKeying, entityName, stateSchema)), why => log.warn("Plugin_Structure", undefined, name + `/` + entityName + ` ` + why));
1010
1053
  let readModelDefs = readModels.map(R => {
1011
1054
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
1012
1055
  let stateSchema = R.Spec.stateSchema;
1013
1056
  let label = labelFieldsFromStateSchema(R.Spec.name, stateSchema);
1014
- let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(R.Spec.name, stateSchema);
1057
+ let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByEnvelope", R.Spec.name, stateSchema);
1015
1058
  let consumed = qualify(name, R.consumedEventNames);
1016
1059
  recordRetired(R.Spec.name, stateSchema);
1017
1060
  recordLifecycle(R.Spec.name, stateSchema);
1018
- recordKeyField(R.Spec.name, stateSchema);
1061
+ recordKeyField("RowKeyedByEnvelope", R.Spec.name, stateSchema);
1019
1062
  return {
1020
1063
  name: R.Spec.name,
1021
1064
  queryField: qf.listFieldName,
@@ -1044,10 +1087,10 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1044
1087
  let consumed = match[1];
1045
1088
  let stateSchema = SVS.Spec.stateSchema;
1046
1089
  let label = labelFieldsFromStateSchema(SVS.Spec.name, stateSchema);
1047
- let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
1090
+ let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField("RowKeyedByStateField", SVS.Spec.name, stateSchema);
1048
1091
  recordRetired(SVS.Spec.name, stateSchema);
1049
1092
  recordLifecycle(SVS.Spec.name, stateSchema);
1050
- recordKeyField(SVS.Spec.name, stateSchema);
1093
+ recordKeyField("RowKeyedByStateField", SVS.Spec.name, stateSchema);
1051
1094
  return {
1052
1095
  name: SVS.Spec.name,
1053
1096
  queryField: qf.listFieldName,
@@ -1101,6 +1144,46 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1101
1144
  };
1102
1145
  }
1103
1146
  let partitionFailures = [];
1147
+ let keyIdentityOf = (stateSchema, idField) => Stdlib_Option.map(idField, field => {
1148
+ let tmp;
1149
+ tmp = stateSchema.type === "object" ? Stdlib_Option.flatMap(stateSchema.properties[field], Semantic$Reventless.identityKey) : undefined;
1150
+ return Stdlib_Option.getOr(tmp, field);
1151
+ });
1152
+ let viewKeys = readModels.map((R, i) => {
1153
+ let def = readModelDefs[i];
1154
+ let declared = Semantic$Reventless.identityKey(R.Spec.Id.schema);
1155
+ let key = declared !== undefined ? declared : keyIdentityOf(R.Spec.stateSchema, def.idField);
1156
+ return [
1157
+ R.Spec.name,
1158
+ key
1159
+ ];
1160
+ }).concat(stateViewSlices.map((SVS, i) => {
1161
+ let def = stateViewDefs[i];
1162
+ return [
1163
+ SVS.Spec.name,
1164
+ keyIdentityOf(SVS.Spec.stateSchema, def.idField)
1165
+ ];
1166
+ }));
1167
+ let viewsByKey = {};
1168
+ viewKeys.forEach(param => {
1169
+ let view = param[0];
1170
+ Stdlib_Option.forEach(param[1], k => {
1171
+ viewsByKey[k] = Stdlib_Option.getOr(viewsByKey[k], []).concat([view]);
1172
+ });
1173
+ });
1174
+ let reported = new Set();
1175
+ let identityViews_viewNames = viewKeys.map(param => param[0]);
1176
+ let identityViews_report = message => {
1177
+ if (!reported.has(message)) {
1178
+ reported.add(message);
1179
+ return log.warn("Plugin_Structure", undefined, name + `: ` + message);
1180
+ }
1181
+ };
1182
+ let identityViews = {
1183
+ viewsByKey: viewsByKey,
1184
+ viewNames: identityViews_viewNames,
1185
+ report: identityViews_report
1186
+ };
1104
1187
  let stateChangeDefs = stateChangeSlices.map((SCS, i) => {
1105
1188
  let match = scsProduced[i];
1106
1189
  let produced = match[1];
@@ -1108,12 +1191,12 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1108
1191
  let consumed = match$1[1];
1109
1192
  return {
1110
1193
  name: SCS.Spec.name,
1111
- commands: extractCommandDefs(false, partitionBySlice[SCS.Spec.name], variantName => Api_Naming$ReventlessCore.sliceMutationFieldFor(name, SCS.Spec.name, SCS.Spec.commandSchema, variantName), SCS.Spec.commandAuthorization, SCS.Spec.commandTransition, extra => derivedEdgeFor(SCS.Spec.name, extra), SCS.Spec.commandSchema),
1194
+ commands: extractCommandDefs(identityViews, false, partitionBySlice[SCS.Spec.name], variantName => Api_Naming$ReventlessCore.sliceMutationFieldFor(name, SCS.Spec.name, SCS.Spec.commandSchema, variantName), SCS.Spec.commandAuthorization, SCS.Spec.commandTransition, extra => derivedEdgeFor(SCS.Spec.name, extra), SCS.Spec.commandSchema),
1112
1195
  producedEventTypes: produced,
1113
1196
  consumedEventTypes: consumed,
1114
1197
  linkedViews: linkedSvsFor(produced),
1115
1198
  consistencyRead: consistencyReadFor(consumed),
1116
- events: extractEventDefs(SCS.Spec.eventSchema),
1199
+ events: extractEventDefs(identityViews, SCS.Spec.eventSchema),
1117
1200
  errors: extractErrorDefs(SCS.Spec.errorSchema),
1118
1201
  chapter: componentChapters[SCS.Spec.name]
1119
1202
  };
@@ -1123,12 +1206,12 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1123
1206
  let produced = match[1];
1124
1207
  return {
1125
1208
  name: A.Spec.name,
1126
- commands: extractCommandDefs(true, undefined, variantName => Api_Naming$ReventlessCore.aggregateMutationField(name, A.Spec.name, variantName), A.Spec.commandAuthorization, A.Spec.commandTransition, extra => derivedEdgeFor(A.Spec.name, extra), A.Spec.commandSchema),
1209
+ commands: extractCommandDefs(identityViews, true, undefined, variantName => Api_Naming$ReventlessCore.aggregateMutationField(name, A.Spec.name, variantName), A.Spec.commandAuthorization, A.Spec.commandTransition, extra => derivedEdgeFor(A.Spec.name, extra), A.Spec.commandSchema),
1127
1210
  producedEventTypes: produced,
1128
1211
  consumedEventTypes: [],
1129
1212
  linkedViews: linkedSvsFor(produced).concat(linkedReadModelsFor(A.Spec.name)),
1130
1213
  consistencyRead: undefined,
1131
- events: extractEventDefs(A.Spec.eventSchema),
1214
+ events: extractEventDefs(identityViews, A.Spec.eventSchema),
1132
1215
  errors: extractErrorDefs(A.Spec.errorSchema),
1133
1216
  chapter: componentChapters[A.Spec.name]
1134
1217
  };
@@ -1452,6 +1535,7 @@ export {
1452
1535
  checkLifecycleTopology,
1453
1536
  labelFieldSourceToString,
1454
1537
  labelFieldsFromStateSchema,
1538
+ identityReference,
1455
1539
  extractReferences,
1456
1540
  toEventDef,
1457
1541
  extractEventDefs,
@@ -1,5 +1,12 @@
1
1
  open JestGlobals
2
2
 
3
+ module OrderId = Reventless.Id.Make({
4
+ let key = "orderId"
5
+ })
6
+ module CustomerId = Reventless.Id.Make({
7
+ let key = "customerId"
8
+ })
9
+
3
10
  // A multi-variant command union serializes as a sury `Union`, the same shape an
4
11
  // aggregate command produces. The generator must decide whether to inject the
5
12
  // aggregate-style `id: ID!` argument from the entry's `injectIdArg` flag, not
@@ -94,7 +101,11 @@ describe("GraphQL_FragmentGenerator slice TODO rows", () => {
94
101
  // common cases; these pin which cases those are, and which still need `@id`.
95
102
  describe("resolveKeyField — the ladder", () => {
96
103
  let rungFor = (~entityName, schema) =>
97
- GraphQL_FragmentGenerator.resolveKeyField(~entityName, schema->S.castToUnknown)
104
+ GraphQL_FragmentGenerator.resolveKeyField(
105
+ ~rowKeying=RowKeyedByStateField,
106
+ ~entityName,
107
+ schema->S.castToUnknown,
108
+ )
98
109
 
99
110
  testSync("a lone `*Id` field is the only thing the key could be", () => {
100
111
  let schema = S.schema(s => {"productId": s.matches(S.string), "name": s.matches(S.string)})
@@ -137,12 +148,65 @@ describe("resolveKeyField — the ladder", () => {
137
148
  })
138
149
  })
139
150
 
151
+ // A read model's row is keyed by the envelope id of the events it folds, so its
152
+ // lone `*Id` field is a foreign id: `Orders = {customerId, …}` is not keyed by the
153
+ // customer.
154
+ describe("resolveKeyField — a read model is keyed by its envelope", () => {
155
+ let keyFor = (~entityName, schema) =>
156
+ GraphQL_FragmentGenerator.resolveKeyField(
157
+ ~rowKeying=RowKeyedByEnvelope,
158
+ ~entityName,
159
+ schema->S.castToUnknown,
160
+ )
161
+
162
+ testSync("a lone foreign `*Id` field does not name the key", () => {
163
+ let schema = S.schema(
164
+ s => {"customerId": s.matches(S.string), "lifecycle": s.matches(S.string)},
165
+ )
166
+ expect(keyFor(~entityName="Orders", schema))->toEqual(None)
167
+ })
168
+
169
+ testSync("the convention rung still names it", () => {
170
+ let schema = S.schema(s => {"orderId": s.matches(S.string), "customerId": s.matches(S.string)})
171
+ expect(keyFor(~entityName="Orders", schema))->toEqual(Some(("orderId", "convention")))
172
+ })
173
+
174
+ // The key is read off the type where there is one: a field typed as the view's
175
+ // own identity keys it whatever it is called, and one typed as another
176
+ // identity never does, whatever it is called.
177
+ testSync("a field typed as the view's identity is its key", () => {
178
+ let schema = S.schema(
179
+ s => {"order": s.matches(OrderId.schema), "customerId": s.matches(CustomerId.schema)},
180
+ )
181
+ expect(keyFor(~entityName="Orders", schema))->toEqual(Some(("order", "convention")))
182
+ })
183
+
184
+ testSync("a field named for the view but typed as another identity is not", () => {
185
+ let schema = S.schema(s => {"orderId": s.matches(CustomerId.schema)})
186
+ expect(keyFor(~entityName="Orders", schema))->toEqual(None)
187
+ })
188
+
189
+ testSync("several foreign `*Id` fields are not a gap worth a warning", () => {
190
+ let schema = S.schema(
191
+ s => {"productId": s.matches(S.string), "categoryId": s.matches(S.string)},
192
+ )
193
+ expect(
194
+ GraphQL_FragmentGenerator.classifyKeyField(
195
+ ~rowKeying=RowKeyedByEnvelope,
196
+ ~entityName="ProductDemand",
197
+ schema->S.castToUnknown,
198
+ )->GraphQL_FragmentGenerator.keyFieldGapMessage,
199
+ )->toEqual(None)
200
+ })
201
+ })
202
+
140
203
  // Losing the key is invisible — the view keeps every field and every row, and
141
204
  // drops its `<field>Eq` filter and its whole `orderBy` from the SDL. These pin
142
205
  // which of the two ways to lose it is worth saying out loud.
143
206
  describe("keyFieldGapMessage — which gap is worth a warning", () => {
144
207
  let gapFor = (~entityName, schema) =>
145
208
  GraphQL_FragmentGenerator.classifyKeyField(
209
+ ~rowKeying=RowKeyedByStateField,
146
210
  ~entityName,
147
211
  schema->S.castToUnknown,
148
212
  )->GraphQL_FragmentGenerator.keyFieldGapMessage
@@ -197,6 +261,17 @@ describe("deriveServerCapability — inferred keys reach the SDL surface", () =>
197
261
  expect(c.sortFields)->toEqual(["orderId"])
198
262
  })
199
263
 
264
+ // Not the key of a read model, but filterable all the same: removing a
265
+ // generated filter is an SDL break.
266
+ testSync("a lone foreign `*Id` field stays filterable and sortable", () => {
267
+ let schema = S.schema(
268
+ s => {"customerId": s.matches(S.string), "lifecycle": s.matches(S.string)},
269
+ )
270
+ let c = capabilityFor(~entityName="Orders", schema)
271
+ expect(c.filterFields->Array.map(f => f.name))->toEqual(["customerId"])
272
+ expect(c.sortFields)->toEqual(["customerId"])
273
+ })
274
+
200
275
  testSync("an unresolvable key still yields nothing — the old behaviour, now narrowed", () => {
201
276
  let schema = S.schema(s => {"email": s.matches(S.string)})
202
277
  let c = capabilityFor(~entityName="Customers", schema)