@reventlessdev/reventless-core 3.0.0-alpha.259 → 3.0.0-alpha.261

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 (209) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +11 -11
  3. package/src/Behavior.res +1 -4
  4. package/src/Message.res +17 -14
  5. package/src/Projection.res +51 -25
  6. package/src/RequestContext.res +8 -9
  7. package/src/ResourceAttribution.res +28 -14
  8. package/src/adapter/Adapter.res +48 -4
  9. package/src/adapter/EventLogProvisioning/EventLogProvisioning.res +5 -5
  10. package/src/adapter/Messaging/Messaging_Log_Backend.res +10 -7
  11. package/src/adapter/Messaging/Messaging_Log_Backend.res.mjs +2 -4
  12. package/src/adapter/Monitoring/Monitoring.res +16 -17
  13. package/src/adapter/QueryInterception/QueryInterception.res +0 -1
  14. package/src/adapter/Runtime/AggregateRuntime_Builder_Common.res +12 -5
  15. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +11 -7
  16. package/src/adapter/Runtime/PluginRuntime_Builder_Micro.res +1 -7
  17. package/src/adapter/Runtime/PluginRuntime_Builder_Single.res +1 -7
  18. package/src/adapter/RuntimeExtension/RuntimeExtension.res +0 -1
  19. package/src/admin/Platform_Admin.res +17 -6
  20. package/src/admin/Platform_AdminApi.res +14 -9
  21. package/src/admin/Platform_Admin_Callback.res +1 -1
  22. package/src/admin/Platform_BakedManifest.res +34 -34
  23. package/src/admin/Platform_ComponentDefinitionsApi.res +9 -21
  24. package/src/admin/Platform_PluginStructuresApi.res +15 -12
  25. package/src/admin/UiFragmentRegistry/StateChange/UiFragmentRegistry.res +1 -1
  26. package/src/components/Aggregate/Aggregate.res +13 -12
  27. package/src/components/Aggregate/Aggregate_Builder.res +22 -21
  28. package/src/components/Aggregate/Aggregate_Callback.res +91 -86
  29. package/src/components/Api/ApiNoApiHelpers.res +1 -3
  30. package/src/components/Api/Api_Ids.res +0 -1
  31. package/src/components/Api/Api_Naming.res +41 -18
  32. package/src/components/Api/GraphQL_FragmentGenerator.res +42 -52
  33. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +11 -4
  34. package/src/components/Api/GraphQL_SchemaInspector.res +5 -3
  35. package/src/components/Api/GraphQL_Stitcher.res +9 -10
  36. package/src/components/Api/MCP_SchemaGenerator.res +8 -12
  37. package/src/components/Api/OwnerScopeDiagnostics.res +1 -2
  38. package/src/components/Api/QueryDbListQuery.res +13 -6
  39. package/src/components/Api/SchemaType.res +40 -20
  40. package/src/components/Api/SchemaType.res.mjs +16 -0
  41. package/src/components/Api/SuryToJsonSchema.res +18 -10
  42. package/src/components/Api/SuryToJsonSchema.res.mjs +135 -121
  43. package/src/components/AutomationSlice/AutomationSlice.res +4 -4
  44. package/src/components/AutomationSlice/AutomationSlice_Builder.res +15 -14
  45. package/src/components/AutomationSlice/AutomationSlice_Callback.res +3 -3
  46. package/src/components/Builder_Helpers.res +12 -23
  47. package/src/components/CommandGenerator/CommandGenerator.res +6 -1
  48. package/src/components/CommandGenerator/CommandGenerator_Builder.res +29 -21
  49. package/src/components/CommandGenerator/CommandGenerator_Callback.res +17 -21
  50. package/src/components/CommandTopic/CommandTopic.res +3 -3
  51. package/src/components/CommandTopic/CommandTopic_Builder.res +10 -10
  52. package/src/components/CommandTopic/CommandTopic_Callback.res +1 -4
  53. package/src/components/CommandTopic/CommandTopic_Helpers.res +1 -2
  54. package/src/components/Counter/Counter_Builder.res +1 -6
  55. package/src/components/Counter/Counter_Callback.res +3 -3
  56. package/src/components/Dcb/Dcb_Builder.res +970 -956
  57. package/src/components/DcbEventLog/DcbEventLog.res +4 -4
  58. package/src/components/DcbEventLog/DcbEventLog_Builder.res +0 -1
  59. package/src/components/DcbEventLog/DcbEventLog_Operations.res +17 -18
  60. package/src/components/EventCollector/EventCollector_Builder.res +2 -1
  61. package/src/components/EventLog/EventLog.res +5 -1
  62. package/src/components/EventLog/EventLog_Operations.res +12 -8
  63. package/src/components/EventMapper/EventMapper.res +8 -10
  64. package/src/components/EventMapper/EventMapper_Builder.res +4 -1
  65. package/src/components/EventMapper/EventMapper_Callback.res +46 -31
  66. package/src/components/EventTopic/EventTopic.res +3 -3
  67. package/src/components/EventTopic/EventTopic_Builder.res +1 -1
  68. package/src/components/Extension/Extension.res +4 -1
  69. package/src/components/Extension/Extension_Operations.res +11 -3
  70. package/src/components/ExtensionPoint/ExtensionPoint.res +3 -3
  71. package/src/components/ExtensionPoint/ExtensionPoint_Builder.res +9 -10
  72. package/src/components/ExtensionPoint/ExtensionPoint_Callback.res +2 -6
  73. package/src/components/ExtensionPoint/ExtensionPoint_Operations.res +13 -5
  74. package/src/components/Heartbeat/Heartbeat_Callback.res +3 -3
  75. package/src/components/InboundTranslationSlice/InboundTranslationSlice.res +4 -4
  76. package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +7 -10
  77. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice.res +4 -4
  78. package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +15 -14
  79. package/src/components/QueryDb/QueryDb_Adapter.res +1 -3
  80. package/src/components/QueryDb/QueryDb_Operations.res +10 -10
  81. package/src/components/ReadModel/ReadModel.res +3 -3
  82. package/src/components/ReadModel/ReadModel_Builder.res +1 -2
  83. package/src/components/ReadModel/ReadModel_Callback.res +2 -1
  84. package/src/components/SideEffectHandler/SideEffectHandler_Callback.res +5 -2
  85. package/src/components/StateChangeSlice/StateChangeSlice.res +3 -3
  86. package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +23 -4
  87. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +22 -6
  88. package/src/components/StateTopic.res +1 -3
  89. package/src/components/StateViewSlice/StateViewSlice.res +4 -4
  90. package/src/components/StateViewSlice/StateViewSlice_Builder.res +79 -68
  91. package/src/components/StateViewSlice/StateViewSlice_Callback.res +3 -1
  92. package/src/components/Task/Task.res +3 -3
  93. package/src/components/Task/Task_Adapter.res +4 -1
  94. package/src/components/Task/Task_Builder.res +24 -21
  95. package/src/plugin/api/PluginBaseFragment.res +9 -3
  96. package/src/plugin/component/Capability_Inference.res +8 -10
  97. package/src/plugin/component/Plugin.res +6 -6
  98. package/src/plugin/component/Plugin_Builder.res +54 -50
  99. package/src/plugin/component/Plugin_Callback.res +10 -6
  100. package/src/plugin/component/Plugin_Helpers.res +264 -230
  101. package/src/plugin/component/Plugin_Structure.res +370 -324
  102. package/src/plugin/component/Plugin_SubscriptionSchema.res +8 -6
  103. package/src/plugin/component/SchemaWalker.res +11 -12
  104. package/src/plugin/connect/PluginConnectExtension_Mapping.res +45 -50
  105. package/src/plugin/lifecycle/PluginBehavior.res +3 -4
  106. package/src/plugin/lifecycle/PluginSpec.res +1 -2
  107. package/src/plugin/lifecycle/PluginsProjection.res +16 -5
  108. package/src/plugin/lifecycle/PluginsReadModelSpec.res +0 -2
  109. package/src/util/CommandPublisher.res +9 -2
  110. package/src/util/CsvStream.res +3 -3
  111. package/src/util/Interstack.res +7 -15
  112. package/src/util/Interstack.resi +3 -3
  113. package/src/util/LogFormat.res +5 -9
  114. package/src/util/Logger.res +1 -2
  115. package/src/util/Lru.res +2 -1
  116. package/src/util/Lru.resi +2 -3
  117. package/src/util/ScheduleOps.res +24 -27
  118. package/src/util/StorePrefixCollision.res +6 -9
  119. package/src/util/Util_Promise.res +4 -1
  120. package/src/util/Util_Promise.res.mjs +1 -1
  121. package/tests/IdentityTest.res +166 -113
  122. package/tests/ResourceAttributionTest.res +28 -25
  123. package/tests/StorePrefixCollisionTest.res +2 -1
  124. package/tests/TaskBucketNamingTest.res +3 -2
  125. package/tests/adapter/EventLogProvisioningTest.res +51 -48
  126. package/tests/adapter/MonitoringTest.res +81 -38
  127. package/tests/adapter/QueryInterceptionTest.res +2 -2
  128. package/tests/adapter/RefusalVocabularyTest.res +31 -23
  129. package/tests/adapter/RuntimeExtensionHookReachTest.res +9 -8
  130. package/tests/adapter/RuntimeExtensionTest.res +5 -3
  131. package/tests/admin/AdminApiSchemaDriftTest.res +22 -16
  132. package/tests/admin/Platform_Admin_StructureTest.res +10 -11
  133. package/tests/admin/Platform_BakedManifestTest.res +31 -35
  134. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +18 -15
  135. package/tests/admin/Platform_PluginStructuresApiTest.res +5 -3
  136. package/tests/admin/Platform_UIFragmentsApiTest.res +6 -3
  137. package/tests/aggregate/AggregateCacheTest.res +10 -12
  138. package/tests/aggregate/AggregateCallbackTest.res +75 -46
  139. package/tests/aggregate/AggregateCausationTest.res +19 -20
  140. package/tests/aggregate/AggregateConflictTest.res +41 -19
  141. package/tests/aggregate/AggregateFixtures.res +15 -5
  142. package/tests/aggregate/AggregateRejectionTest.res +97 -80
  143. package/tests/aggregate/AggregateSnapshotTest.res +44 -16
  144. package/tests/api/Api_IdsTest.res +10 -4
  145. package/tests/api/BrandedMarkerCompositionTest.res +48 -36
  146. package/tests/api/GeolocationSemanticTest.res +3 -3
  147. package/tests/api/GraphQL_FragmentGeneratorTest.res +66 -54
  148. package/tests/api/OwnerScopeDiagnosticsTest.res +31 -27
  149. package/tests/api/StorageRefFieldsTest.res +30 -18
  150. package/tests/api/SuryToJsonSchemaTest.res +1338 -981
  151. package/tests/api/SuryToJsonSchemaTest.res.mjs +31 -0
  152. package/tests/api/TaggedUnionSchemaTest.res +17 -24
  153. package/tests/commandgenerator/CommandGeneratorCallbackTest.res +235 -179
  154. package/tests/commandgenerator/CommandGeneratorFixtures.res +8 -13
  155. package/tests/commandgenerator/OwnerStampingTest.res +91 -76
  156. package/tests/commandtopic/CommandOutcomeHookTest.res +198 -155
  157. package/tests/commandtopic/CommandTopicCallbackFixtures.res +5 -4
  158. package/tests/commandtopic/CommandTopicCallbackTest.res +61 -43
  159. package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +70 -55
  160. package/tests/counter/CounterCallbackTest.res +40 -34
  161. package/tests/counter/CounterFixtures.res +4 -4
  162. package/tests/dcb/DcbDecodeTest.res +115 -84
  163. package/tests/dcb/DcbEventLogOperationsTest.res +194 -143
  164. package/tests/dcb/DcbEventLogSchemaTest.res +13 -13
  165. package/tests/dcb/DcbFixtures.res +4 -5
  166. package/tests/dcb/DcbScopeInferenceTest.res +173 -114
  167. package/tests/dcb/DcbStateChangeSliceTest.res +391 -262
  168. package/tests/dcb/DcbTagTest.res +22 -11
  169. package/tests/dcb/DcbValidationTest.res +288 -218
  170. package/tests/eventlog/EventLogFixtures.res +4 -4
  171. package/tests/eventlog/EventLogOperationsTest.res +102 -67
  172. package/tests/eventlog/EventLogRetryTest.res +104 -65
  173. package/tests/eventmapper/EventMapperCallbackTest.res +118 -83
  174. package/tests/eventmapper/EventMapperFixtures.res +15 -23
  175. package/tests/eventmapper/EventMapperTest.res +54 -38
  176. package/tests/eventtopic/EventTopicFixtures.res +3 -4
  177. package/tests/eventtopic/EventTopicOperationsTest.res +41 -29
  178. package/tests/extensionpoint/ExtensionPointCallbackTest.res +58 -45
  179. package/tests/extensionpoint/ExtensionPointFixtures.res +16 -20
  180. package/tests/extensionpoint/ExtensionPointOperationsTest.res +70 -49
  181. package/tests/extensionpoint/PluginDisconnectScheduleTest.res +2 -1
  182. package/tests/logger/LogFormatTest.res +104 -82
  183. package/tests/logger/LoggerLazyTest.res +21 -12
  184. package/tests/message/MessageTest.res +96 -93
  185. package/tests/message/MetaEnvelopeTest.res +26 -30
  186. package/tests/plugin/HeartbeatDisconnectGraceTest.res +31 -25
  187. package/tests/plugin/ManifestVisibilityTest.res +20 -12
  188. package/tests/plugin/PluginDefinitionScalars.res +1 -2
  189. package/tests/plugin/PluginLifecycleCorpusTest.res +15 -12
  190. package/tests/plugin/PluginSpecExperiment.res +0 -1
  191. package/tests/plugin/PluginStructureTest.res +1533 -1030
  192. package/tests/plugin/PluginStructureTest.res.mjs +6 -0
  193. package/tests/plugin/Plugin_EventQuerySchemaTest.res +21 -10
  194. package/tests/plugin/SchemaWalkerTest.res +116 -70
  195. package/tests/plugin/StateChange/PsReserveStock.res +4 -4
  196. package/tests/plugin/StateView/PsAnnotatedView.res +6 -4
  197. package/tests/plugin/StateView/PsAnnotatedView.res.mjs +7 -3
  198. package/tests/plugin/StateView/PsCustomersView.res +4 -4
  199. package/tests/plugin/StateView/PsShipmentsView.res +3 -2
  200. package/tests/projection/LifecycleTrailTest.res +1 -4
  201. package/tests/projection/ProjectionOptimizeTest.res +13 -9
  202. package/tests/querydb/QueryDbFixtures.res +0 -1
  203. package/tests/querydb/QueryDbOperationsTest.res +99 -64
  204. package/tests/querydb/TaggedUnionStampTest.res +10 -8
  205. package/tests/sideeffecthandler/SideEffectHandlerCallbackTest.res +50 -35
  206. package/tests/sideeffecthandler/SideEffectHandlerFixtures.res +4 -10
  207. package/tests/util/CommandPublisherTest.res +0 -1
  208. package/tests/util/CsvStreamTest.res +3 -3
  209. package/tests/util/LruTest.res +84 -56
@@ -40,70 +40,82 @@ let emptySpec: Reventless.StateAnnotations.stateAnnotationSpec = {
40
40
 
41
41
  describe("SuryToJsonSchema:", () => {
42
42
  describe("deriveObjectSchema with no annotations:", () => {
43
- testSync("emits plain JSON Schema when no metadata is attached", () => {
44
- let schema = S.schema(s =>
45
- {
46
- "id": s.matches(S.string),
47
- "name": s.matches(S.string),
48
- }
49
- )->S.castToUnknown
50
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
51
- let idSchema = getPropertyOf(json, "id")
52
- expect(
53
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")),
54
- )->toBe(None)
55
- })
56
-
57
- testSync("emits format:\"date-time\" for a DateTime-marked string field", () => {
58
- let schema = S.schema(s =>
59
- {
60
- "placedAt": s.matches(Reventless.DateTime.schema),
61
- "name": s.matches(S.string),
62
- }
63
- )->S.castToUnknown
64
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
65
- expect(
66
- getPropertyOf(json, "placedAt")
67
- ->Option.flatMap(s => getProperty(s, "format"))
68
- ->Option.flatMap(JSON.Decode.string),
69
- )->toBe(Some("date-time"))
70
- // A plain string field stays plain — the marker is opt-in.
71
- expect(
72
- getPropertyOf(json, "name")->Option.flatMap(s => getProperty(s, "format")),
73
- )->toBe(None)
74
- })
43
+ testSync(
44
+ "emits plain JSON Schema when no metadata is attached",
45
+ () => {
46
+ let schema = S.schema(
47
+ s =>
48
+ {
49
+ "id": s.matches(S.string),
50
+ "name": s.matches(S.string),
51
+ },
52
+ )->S.castToUnknown
53
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
54
+ let idSchema = getPropertyOf(json, "id")
55
+ expect(idSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")))->toBe(None)
56
+ },
57
+ )
58
+
59
+ testSync(
60
+ "emits format:\"date-time\" for a DateTime-marked string field",
61
+ () => {
62
+ let schema = S.schema(
63
+ s =>
64
+ {
65
+ "placedAt": s.matches(Reventless.DateTime.schema),
66
+ "name": s.matches(S.string),
67
+ },
68
+ )->S.castToUnknown
69
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
70
+ expect(
71
+ getPropertyOf(json, "placedAt")
72
+ ->Option.flatMap(s => getProperty(s, "format"))
73
+ ->Option.flatMap(JSON.Decode.string),
74
+ )->toBe(Some("date-time"))
75
+ // A plain string field stays plain — the marker is opt-in.
76
+ expect(getPropertyOf(json, "name")->Option.flatMap(s => getProperty(s, "format")))->toBe(
77
+ None,
78
+ )
79
+ },
80
+ )
75
81
 
76
82
  // The no-drift claim behind writing `placedAt: Reventless.DateTime.t` in
77
83
  // place of `@s.matches(DateTime.string) string`: the type emits exactly what
78
84
  // the annotation emitted, semantic key still absent. This is the one test
79
85
  // that would catch that output changing by accident.
80
- testSync("a DateTime field emits format and no semantic key", () => {
81
- let json = SuryToJsonSchema.deriveObjectSchema(
82
- S.schema(s => {"placedAt": s.matches(Reventless.DateTime.schema)})->S.castToUnknown,
83
- )
84
- let keyOf = key => getPropertyOf(json, "placedAt")->Option.flatMap(s => getProperty(s, key))
85
- expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
86
- Some(JSON.Encode.string("string")),
87
- Some(JSON.Encode.string("date-time")),
88
- None,
89
- ))
90
- })
86
+ testSync(
87
+ "a DateTime field emits format and no semantic key",
88
+ () => {
89
+ let json = SuryToJsonSchema.deriveObjectSchema(
90
+ S.schema(s => {"placedAt": s.matches(Reventless.DateTime.schema)})->S.castToUnknown,
91
+ )
92
+ let keyOf = key => getPropertyOf(json, "placedAt")->Option.flatMap(s => getProperty(s, key))
93
+ expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
94
+ Some(JSON.Encode.string("string")),
95
+ Some(JSON.Encode.string("date-time")),
96
+ None,
97
+ ))
98
+ },
99
+ )
91
100
 
92
101
  // A calendar date speaks both vocabularies, and that asymmetry with
93
102
  // `DateTime` is deliberate: `DateTime`'s bare-`format` output predates the
94
103
  // generic marker and is a published contract, so it keeps it; a semantic
95
104
  // arriving now has no such history and should say what it is as well.
96
- testSync("a CalendarDate field emits format:\"date\" AND the semantic key", () => {
97
- let json = SuryToJsonSchema.deriveObjectSchema(
98
- S.schema(s => {"issuedOn": s.matches(Reventless.CalendarDate.schema)})->S.castToUnknown,
99
- )
100
- let keyOf = key => getPropertyOf(json, "issuedOn")->Option.flatMap(s => getProperty(s, key))
101
- expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
102
- Some(JSON.Encode.string("string")),
103
- Some(JSON.Encode.string("date")),
104
- Some(JSON.Encode.string("date")),
105
- ))
106
- })
105
+ testSync(
106
+ "a CalendarDate field emits format:\"date\" AND the semantic key",
107
+ () => {
108
+ let json = SuryToJsonSchema.deriveObjectSchema(
109
+ S.schema(s => {"issuedOn": s.matches(Reventless.CalendarDate.schema)})->S.castToUnknown,
110
+ )
111
+ let keyOf = key => getPropertyOf(json, "issuedOn")->Option.flatMap(s => getProperty(s, key))
112
+ expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
113
+ Some(JSON.Encode.string("string")),
114
+ Some(JSON.Encode.string("date")),
115
+ Some(JSON.Encode.string("date")),
116
+ ))
117
+ },
118
+ )
107
119
  })
108
120
 
109
121
  describe("deriveObjectSchema with an owner field:", () => {
@@ -111,44 +123,53 @@ describe("SuryToJsonSchema:", () => {
111
123
  getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
112
124
 
113
125
  let json = SuryToJsonSchema.deriveObjectSchema(
114
- S.schema(s =>
115
- {
116
- "customerId": s.matches(Reventless.Owner.string),
117
- "note": s.matches(S.string),
118
- }
126
+ S.schema(
127
+ s =>
128
+ {
129
+ "customerId": s.matches(Reventless.Owner.string),
130
+ "note": s.matches(S.string),
131
+ },
119
132
  )->S.castToUnknown,
120
133
  )
121
134
 
122
- testSync("the marked field carries x-reventless-owner", () =>
123
- expect(ownerOf(json, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
135
+ testSync(
136
+ "the marked field carries x-reventless-owner",
137
+ () => expect(ownerOf(json, "customerId"))->toEqual(Some(JSON.Encode.bool(true))),
124
138
  )
125
139
 
126
140
  // The control: ownership is opt-in, and an unmarked string must stay one.
127
141
  // Without this, a bug that marked every field would still pass above.
128
- testSync("an unmarked string field carries nothing", () =>
129
- expect(ownerOf(json, "note"))->toBe(None)
142
+ testSync(
143
+ "an unmarked string field carries nothing",
144
+ () => expect(ownerOf(json, "note"))->toBe(None),
130
145
  )
131
146
 
132
- testSync("the field is still a plain string on the wire", () =>
133
- expect(
134
- getPropertyOf(json, "customerId")->Option.flatMap(s => getProperty(s, "type")),
135
- )->toEqual(Some(JSON.Encode.string("string")))
147
+ testSync(
148
+ "the field is still a plain string on the wire",
149
+ () =>
150
+ expect(
151
+ getPropertyOf(json, "customerId")->Option.flatMap(s => getProperty(s, "type")),
152
+ )->toEqual(Some(JSON.Encode.string("string"))),
136
153
  )
137
154
 
138
155
  // An optional field keeps its marker inside the union wrapper. A reader that
139
156
  // only inspects the outer schema answers "not the owner" here — which for a
140
157
  // predicate that scopes reads means an unscoped view, silently. This is the
141
158
  // case `Owner.isFieldOwner` exists for.
142
- testSync("an optional owner field is still recognised", () => {
143
- let optJson = SuryToJsonSchema.deriveObjectSchema(
144
- S.schema(s =>
145
- {
146
- "customerId": s.matches(S.option(Reventless.Owner.string)),
147
- }
148
- )->S.castToUnknown,
149
- )
150
- expect(ownerOf(optJson, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
151
- })
159
+ testSync(
160
+ "an optional owner field is still recognised",
161
+ () => {
162
+ let optJson = SuryToJsonSchema.deriveObjectSchema(
163
+ S.schema(
164
+ s =>
165
+ {
166
+ "customerId": s.matches(S.option(Reventless.Owner.string)),
167
+ },
168
+ )->S.castToUnknown,
169
+ )
170
+ expect(ownerOf(optJson, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
171
+ },
172
+ )
152
173
  })
153
174
 
154
175
  describe("deriveObjectSchema with a sensitive field:", () => {
@@ -158,449 +179,557 @@ describe("SuryToJsonSchema:", () => {
158
179
  getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
159
180
 
160
181
  let json = SuryToJsonSchema.deriveObjectSchema(
161
- S.schema(s =>
162
- {
163
- "resetToken": s.matches(Reventless.Sensitive.string),
164
- "note": s.matches(S.string),
165
- "contact": s.matches(Reventless.Email.schema),
166
- }
182
+ S.schema(
183
+ s =>
184
+ {
185
+ "resetToken": s.matches(Reventless.Sensitive.string),
186
+ "note": s.matches(S.string),
187
+ "contact": s.matches(Reventless.Email.schema),
188
+ },
167
189
  )->S.castToUnknown,
168
190
  )
169
191
 
170
- testSync("the marked field carries x-reventless-sensitive", () =>
171
- expect(sensitiveOf(json, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
192
+ testSync(
193
+ "the marked field carries x-reventless-sensitive",
194
+ () => expect(sensitiveOf(json, "resetToken"))->toEqual(Some(JSON.Encode.bool(true))),
172
195
  )
173
196
 
174
197
  // The control. Absent means "not stated", and a reader that misses the
175
198
  // marker renders the value — so a bug that marked everything would look like
176
199
  // caution while a bug that marked nothing leaks. Both need catching, and
177
200
  // only this assertion catches the first.
178
- testSync("an unmarked string field carries nothing", () =>
179
- expect(sensitiveOf(json, "note"))->toBe(None)
201
+ testSync(
202
+ "an unmarked string field carries nothing",
203
+ () => expect(sensitiveOf(json, "note"))->toBe(None),
180
204
  )
181
205
 
182
206
  // A contact detail is sensitive whether or not anybody wrote it down: the
183
207
  // whole meaning of the semantic is "how to reach a particular person".
184
- testSync("an email field is sensitive with no annotation", () =>
185
- expect(sensitiveOf(json, "contact"))->toEqual(Some(JSON.Encode.bool(true)))
208
+ testSync(
209
+ "an email field is sensitive with no annotation",
210
+ () => expect(sensitiveOf(json, "contact"))->toEqual(Some(JSON.Encode.bool(true))),
186
211
  )
187
212
 
188
- testSync("the field is still a plain string on the wire", () =>
189
- expect(
190
- getPropertyOf(json, "resetToken")->Option.flatMap(s => getProperty(s, "type")),
191
- )->toEqual(Some(JSON.Encode.string("string")))
213
+ testSync(
214
+ "the field is still a plain string on the wire",
215
+ () =>
216
+ expect(
217
+ getPropertyOf(json, "resetToken")->Option.flatMap(s => getProperty(s, "type")),
218
+ )->toEqual(Some(JSON.Encode.string("string"))),
192
219
  )
193
220
 
194
221
  // Same trap as the owner case, and worse in consequence: a reader that only
195
222
  // inspects the outer schema answers "not sensitive" for an optional field,
196
223
  // and the value goes into a message.
197
- testSync("an optional sensitive field is still recognised", () => {
198
- let optJson = SuryToJsonSchema.deriveObjectSchema(
199
- S.schema(s =>
200
- {
201
- "resetToken": s.matches(S.option(Reventless.Sensitive.string)),
202
- }
203
- )->S.castToUnknown,
204
- )
205
- expect(sensitiveOf(optJson, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
206
- })
224
+ testSync(
225
+ "an optional sensitive field is still recognised",
226
+ () => {
227
+ let optJson = SuryToJsonSchema.deriveObjectSchema(
228
+ S.schema(
229
+ s =>
230
+ {
231
+ "resetToken": s.matches(S.option(Reventless.Sensitive.string)),
232
+ },
233
+ )->S.castToUnknown,
234
+ )
235
+ expect(sensitiveOf(optJson, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
236
+ },
237
+ )
207
238
 
208
239
  // The composition rule. `mark` wraps rather than replaces, so a field that
209
240
  // is both an owner and sensitive keeps both — a marker that silently
210
241
  // replaced the other would unscope a view or unmask a value.
211
- testSync("sensitivity composes with ownership on one field", () => {
212
- let bothJson = SuryToJsonSchema.deriveObjectSchema(
213
- S.schema(s =>
214
- {
215
- "customerId": s.matches(Reventless.Sensitive.mark(Reventless.Owner.string)),
216
- }
217
- )->S.castToUnknown,
218
- )
219
- expect((sensitiveOf(bothJson, "customerId"), ownerOf(bothJson, "customerId")))->toEqual((
220
- Some(JSON.Encode.bool(true)),
221
- Some(JSON.Encode.bool(true)),
222
- ))
223
- })
242
+ testSync(
243
+ "sensitivity composes with ownership on one field",
244
+ () => {
245
+ let bothJson = SuryToJsonSchema.deriveObjectSchema(
246
+ S.schema(
247
+ s =>
248
+ {
249
+ "customerId": s.matches(Reventless.Sensitive.mark(Reventless.Owner.string)),
250
+ },
251
+ )->S.castToUnknown,
252
+ )
253
+ expect((sensitiveOf(bothJson, "customerId"), ownerOf(bothJson, "customerId")))->toEqual((
254
+ Some(JSON.Encode.bool(true)),
255
+ Some(JSON.Encode.bool(true)),
256
+ ))
257
+ },
258
+ )
224
259
  })
225
260
 
226
261
  describe("deriveObjectSchema with stateAnnotations metadata:", () => {
227
262
  let withSpec = (schema, spec) =>
228
263
  schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
229
264
 
230
- testSync("emits x-reventless-id on field listed in ids", () => {
231
- let schema = S.schema(s =>
232
- {
233
- "entityId": s.matches(S.string),
234
- "name": s.matches(S.string),
235
- }
236
- )->S.castToUnknown
237
- let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
238
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
239
- let entityIdSchema = getPropertyOf(json, "entityId")
240
- expect(
241
- entityIdSchema
242
- ->Option.flatMap(s => getProperty(s, "x-reventless-id"))
243
- ->Option.flatMap(JSON.Decode.bool),
244
- )->toBe(Some(true))
245
- })
246
-
247
- testSync("emits x-reventless-compositeId on each compositeId field", () => {
248
- let schema = S.schema(s =>
249
- {
250
- "environment": s.matches(S.string),
251
- "platformName": s.matches(S.string),
252
- "name": s.matches(S.string),
253
- }
254
- )->S.castToUnknown
255
- let schema' = schema->withSpec({
256
- ...emptySpec,
257
- compositeIds: ["environment", "platformName"],
258
- })
259
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
260
- let envSchema = getPropertyOf(json, "environment")
261
- let platformSchema = getPropertyOf(json, "platformName")
262
- expect((
263
- envSchema
264
- ->Option.flatMap(s => getProperty(s, "x-reventless-compositeId"))
265
- ->Option.flatMap(JSON.Decode.bool),
266
- platformSchema
267
- ->Option.flatMap(s => getProperty(s, "x-reventless-compositeId"))
268
- ->Option.flatMap(JSON.Decode.bool),
269
- ))->toEqual((Some(true), Some(true)))
270
- })
271
-
272
- testSync("emits x-reventless-subId on field listed in subIds", () => {
273
- let schema = S.schema(s =>
274
- {
275
- "itemId": s.matches(S.string),
276
- "version": s.matches(S.string),
277
- }
278
- )->S.castToUnknown
279
- let schema' = schema->withSpec({...emptySpec, subIds: ["version"]})
280
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
281
- let versionSchema = getPropertyOf(json, "version")
282
- expect(
283
- versionSchema
284
- ->Option.flatMap(s => getProperty(s, "x-reventless-subId"))
285
- ->Option.flatMap(JSON.Decode.bool),
286
- )->toBe(Some(true))
287
- })
288
-
289
- testSync("emits x-reventless-index as the index name when named", () => {
290
- let schema = S.schema(s =>
291
- {
292
- "id": s.matches(S.string),
293
- "ownerId": s.matches(S.string),
294
- }
295
- )->S.castToUnknown
296
- let schema' = schema->withSpec({
297
- ...emptySpec,
298
- indexes: [("ownerId", "byOwner")],
299
- })
300
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
301
- let ownerIdSchema = getPropertyOf(json, "ownerId")
302
- expect(
303
- ownerIdSchema
304
- ->Option.flatMap(s => getProperty(s, "x-reventless-index"))
305
- ->Option.flatMap(JSON.Decode.string),
306
- )->toBe(Some("byOwner"))
307
- })
308
-
309
- testSync("emits x-reventless-index as true for unnamed @index", () => {
310
- let schema = S.schema(s =>
311
- {
312
- "id": s.matches(S.string),
313
- "category": s.matches(S.string),
314
- }
315
- )->S.castToUnknown
316
- let schema' = schema->withSpec({
317
- ...emptySpec,
318
- indexes: [("category", "")],
319
- })
320
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
321
- let categorySchema = getPropertyOf(json, "category")
322
- expect(
323
- categorySchema
324
- ->Option.flatMap(s => getProperty(s, "x-reventless-index"))
325
- ->Option.flatMap(JSON.Decode.bool),
326
- )->toBe(Some(true))
327
- })
328
-
329
- testSync("does not emit x-reventless-* on unannotated fields", () => {
330
- let schema = S.schema(s =>
331
- {
332
- "entityId": s.matches(S.string),
333
- "name": s.matches(S.string),
334
- }
335
- )->S.castToUnknown
336
- let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
337
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
338
- let nameSchema = getPropertyOf(json, "name")
339
- expect(
340
- nameSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")),
341
- )->toBe(None)
342
- })
343
-
344
- testSync("emits x-reventless-hidden on field listed in hidden", () => {
345
- let schema = S.schema(s =>
346
- {
347
- "id": s.matches(S.string),
348
- "deploymentId": s.matches(S.string),
349
- }
350
- )->S.castToUnknown
351
- let schema' = schema->withSpec({...emptySpec, hidden: ["deploymentId"]})
352
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
353
- let deploymentIdSchema = getPropertyOf(json, "deploymentId")
354
- let idSchema = getPropertyOf(json, "id")
355
- expect((
356
- deploymentIdSchema
357
- ->Option.flatMap(s => getProperty(s, "x-reventless-hidden"))
358
- ->Option.flatMap(JSON.Decode.bool),
359
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-hidden")),
360
- ))->toEqual((Some(true), None))
361
- })
265
+ testSync(
266
+ "emits x-reventless-id on field listed in ids",
267
+ () => {
268
+ let schema = S.schema(
269
+ s =>
270
+ {
271
+ "entityId": s.matches(S.string),
272
+ "name": s.matches(S.string),
273
+ },
274
+ )->S.castToUnknown
275
+ let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
276
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
277
+ let entityIdSchema = getPropertyOf(json, "entityId")
278
+ expect(
279
+ entityIdSchema
280
+ ->Option.flatMap(s => getProperty(s, "x-reventless-id"))
281
+ ->Option.flatMap(JSON.Decode.bool),
282
+ )->toBe(Some(true))
283
+ },
284
+ )
285
+
286
+ testSync(
287
+ "emits x-reventless-compositeId on each compositeId field",
288
+ () => {
289
+ let schema = S.schema(
290
+ s =>
291
+ {
292
+ "environment": s.matches(S.string),
293
+ "platformName": s.matches(S.string),
294
+ "name": s.matches(S.string),
295
+ },
296
+ )->S.castToUnknown
297
+ let schema' = schema->withSpec({
298
+ ...emptySpec,
299
+ compositeIds: ["environment", "platformName"],
300
+ })
301
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
302
+ let envSchema = getPropertyOf(json, "environment")
303
+ let platformSchema = getPropertyOf(json, "platformName")
304
+ expect((
305
+ envSchema
306
+ ->Option.flatMap(s => getProperty(s, "x-reventless-compositeId"))
307
+ ->Option.flatMap(JSON.Decode.bool),
308
+ platformSchema
309
+ ->Option.flatMap(s => getProperty(s, "x-reventless-compositeId"))
310
+ ->Option.flatMap(JSON.Decode.bool),
311
+ ))->toEqual((Some(true), Some(true)))
312
+ },
313
+ )
314
+
315
+ testSync(
316
+ "emits x-reventless-subId on field listed in subIds",
317
+ () => {
318
+ let schema = S.schema(
319
+ s =>
320
+ {
321
+ "itemId": s.matches(S.string),
322
+ "version": s.matches(S.string),
323
+ },
324
+ )->S.castToUnknown
325
+ let schema' = schema->withSpec({...emptySpec, subIds: ["version"]})
326
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
327
+ let versionSchema = getPropertyOf(json, "version")
328
+ expect(
329
+ versionSchema
330
+ ->Option.flatMap(s => getProperty(s, "x-reventless-subId"))
331
+ ->Option.flatMap(JSON.Decode.bool),
332
+ )->toBe(Some(true))
333
+ },
334
+ )
335
+
336
+ testSync(
337
+ "emits x-reventless-index as the index name when named",
338
+ () => {
339
+ let schema = S.schema(
340
+ s =>
341
+ {
342
+ "id": s.matches(S.string),
343
+ "ownerId": s.matches(S.string),
344
+ },
345
+ )->S.castToUnknown
346
+ let schema' = schema->withSpec({
347
+ ...emptySpec,
348
+ indexes: [("ownerId", "byOwner")],
349
+ })
350
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
351
+ let ownerIdSchema = getPropertyOf(json, "ownerId")
352
+ expect(
353
+ ownerIdSchema
354
+ ->Option.flatMap(s => getProperty(s, "x-reventless-index"))
355
+ ->Option.flatMap(JSON.Decode.string),
356
+ )->toBe(Some("byOwner"))
357
+ },
358
+ )
359
+
360
+ testSync(
361
+ "emits x-reventless-index as true for unnamed @index",
362
+ () => {
363
+ let schema = S.schema(
364
+ s =>
365
+ {
366
+ "id": s.matches(S.string),
367
+ "category": s.matches(S.string),
368
+ },
369
+ )->S.castToUnknown
370
+ let schema' = schema->withSpec({
371
+ ...emptySpec,
372
+ indexes: [("category", "")],
373
+ })
374
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
375
+ let categorySchema = getPropertyOf(json, "category")
376
+ expect(
377
+ categorySchema
378
+ ->Option.flatMap(s => getProperty(s, "x-reventless-index"))
379
+ ->Option.flatMap(JSON.Decode.bool),
380
+ )->toBe(Some(true))
381
+ },
382
+ )
383
+
384
+ testSync(
385
+ "does not emit x-reventless-* on unannotated fields",
386
+ () => {
387
+ let schema = S.schema(
388
+ s =>
389
+ {
390
+ "entityId": s.matches(S.string),
391
+ "name": s.matches(S.string),
392
+ },
393
+ )->S.castToUnknown
394
+ let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
395
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
396
+ let nameSchema = getPropertyOf(json, "name")
397
+ expect(nameSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")))->toBe(None)
398
+ },
399
+ )
400
+
401
+ testSync(
402
+ "emits x-reventless-hidden on field listed in hidden",
403
+ () => {
404
+ let schema = S.schema(
405
+ s =>
406
+ {
407
+ "id": s.matches(S.string),
408
+ "deploymentId": s.matches(S.string),
409
+ },
410
+ )->S.castToUnknown
411
+ let schema' = schema->withSpec({...emptySpec, hidden: ["deploymentId"]})
412
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
413
+ let deploymentIdSchema = getPropertyOf(json, "deploymentId")
414
+ let idSchema = getPropertyOf(json, "id")
415
+ expect((
416
+ deploymentIdSchema
417
+ ->Option.flatMap(s => getProperty(s, "x-reventless-hidden"))
418
+ ->Option.flatMap(JSON.Decode.bool),
419
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-hidden")),
420
+ ))->toEqual((Some(true), None))
421
+ },
422
+ )
362
423
 
363
424
  // `@internal` drops the property rather than marking it. A marker is the
364
425
  // right answer for a field that is on the surface and merely unwanted — that
365
426
  // is `@hidden`, above. This one is not on the surface at all, so an entry
366
427
  // here would describe a field the SDL does not declare, which is the
367
428
  // mismatch that makes a generated list query name fields the server rejects.
368
- testSync("drops a field listed in internal rather than annotating it", () => {
369
- let schema = S.schema(s =>
370
- {
371
- "id": s.matches(S.string),
372
- "eventCollector": s.matches(S.string),
373
- }
374
- )->S.castToUnknown
375
- let schema' = schema->withSpec({...emptySpec, internal: ["eventCollector"]})
376
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
377
- expect((
378
- getPropertyOf(json, "eventCollector")->Option.isNone,
379
- getPropertyOf(json, "id")->Option.isSome,
380
- ))->toEqual((true, true))
381
- })
429
+ testSync(
430
+ "drops a field listed in internal rather than annotating it",
431
+ () => {
432
+ let schema = S.schema(
433
+ s =>
434
+ {
435
+ "id": s.matches(S.string),
436
+ "eventCollector": s.matches(S.string),
437
+ },
438
+ )->S.castToUnknown
439
+ let schema' = schema->withSpec({...emptySpec, internal: ["eventCollector"]})
440
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
441
+ expect((
442
+ getPropertyOf(json, "eventCollector")->Option.isNone,
443
+ getPropertyOf(json, "id")->Option.isSome,
444
+ ))->toEqual((true, true))
445
+ },
446
+ )
382
447
 
383
448
  // A dropped property must not be left behind in `required`, or a consumer
384
449
  // building a form from the schema asks for a field it was never given.
385
- testSync("keeps a dropped field out of required", () => {
386
- let schema = S.schema(s =>
387
- {
388
- "id": s.matches(S.string),
389
- "eventCollector": s.matches(S.string),
390
- }
391
- )->S.castToUnknown
392
- let json =
393
- schema
394
- ->withSpec({...emptySpec, internal: ["eventCollector"]})
395
- ->SuryToJsonSchema.deriveObjectSchema
396
- expect(
397
- json
398
- ->JSON.Decode.object
399
- ->Option.flatMap(o => o->Dict.get("required"))
400
- ->Option.flatMap(JSON.Decode.array)
401
- ->Option.getOr([])
402
- ->Array.filterMap(JSON.Decode.string),
403
- )->toEqual(["id"])
404
- })
405
-
406
- testSync("emits x-reventless-summary on field listed in summary", () => {
407
- let schema = S.schema(s =>
408
- {
409
- "id": s.matches(S.string),
410
- "pluginName": s.matches(S.string),
411
- }
412
- )->S.castToUnknown
413
- let schema' = schema->withSpec({...emptySpec, summary: ["pluginName"]})
414
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
415
- let pluginNameSchema = getPropertyOf(json, "pluginName")
416
- let idSchema = getPropertyOf(json, "id")
417
- expect((
418
- pluginNameSchema
419
- ->Option.flatMap(s => getProperty(s, "x-reventless-summary"))
420
- ->Option.flatMap(JSON.Decode.bool),
421
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-summary")),
422
- ))->toEqual((Some(true), None))
423
- })
424
-
425
- testSync("emits x-reventless-drillTarget as the slice name", () => {
426
- let schema = S.schema(s =>
427
- {
428
- "id": s.matches(S.string),
429
- "components": s.matches(S.string),
430
- }
431
- )->S.castToUnknown
432
- let schema' = schema->withSpec({
433
- ...emptySpec,
434
- drillTargets: [("components", "ResourceInventory")],
435
- })
436
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
437
- let componentsSchema = getPropertyOf(json, "components")
438
- let idSchema = getPropertyOf(json, "id")
439
- expect((
440
- componentsSchema
441
- ->Option.flatMap(s => getProperty(s, "x-reventless-drillTarget"))
442
- ->Option.flatMap(JSON.Decode.string),
443
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-drillTarget")),
444
- ))->toEqual((Some("ResourceInventory"), None))
445
- })
446
-
447
- testSync("emits x-reventless-drillTargetKey as the key path", () => {
448
- let schema = S.schema(s =>
449
- {
450
- "id": s.matches(S.string),
451
- "components": s.matches(S.string),
452
- }
453
- )->S.castToUnknown
454
- let schema' = schema->withSpec({
455
- ...emptySpec,
456
- drillTargets: [("components", "ResourceInventory")],
457
- drillTargetKeys: [("components", "kind/name")],
458
- })
459
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
460
- let componentsSchema = getPropertyOf(json, "components")
461
- expect(
462
- componentsSchema
463
- ->Option.flatMap(s => getProperty(s, "x-reventless-drillTargetKey"))
464
- ->Option.flatMap(JSON.Decode.string),
465
- )->toBe(Some("kind/name"))
466
- })
467
-
468
- testSync("does not emit x-reventless-drillTargetKey when no key was supplied", () => {
469
- let schema = S.schema(s =>
470
- {
471
- "id": s.matches(S.string),
472
- "components": s.matches(S.string),
473
- }
474
- )->S.castToUnknown
475
- let schema' = schema->withSpec({
476
- ...emptySpec,
477
- drillTargets: [("components", "ResourceInventory")],
478
- })
479
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
480
- let componentsSchema = getPropertyOf(json, "components")
481
- expect(
482
- componentsSchema->Option.flatMap(s => getProperty(s, "x-reventless-drillTargetKey")),
483
- )->toBe(None)
484
- })
485
-
486
- testSync("emits x-reventless-scan on field listed in scan", () => {
487
- let schema = S.schema(s =>
488
- {
489
- "id": s.matches(S.string),
490
- "status": s.matches(S.string),
491
- }
492
- )->S.castToUnknown
493
- let schema' = schema->withSpec({...emptySpec, scan: ["status"]})
494
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
495
- let statusSchema = getPropertyOf(json, "status")
496
- let idSchema = getPropertyOf(json, "id")
497
- expect((
498
- statusSchema
499
- ->Option.flatMap(s => getProperty(s, "x-reventless-scan"))
500
- ->Option.flatMap(JSON.Decode.bool),
501
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-scan")),
502
- ))->toEqual((Some(true), None))
503
- })
504
-
505
- testSync("emits x-reventless-scanSort on field listed in scanSort", () => {
506
- let schema = S.schema(s =>
507
- {
508
- "id": s.matches(S.string),
509
- "name": s.matches(S.string),
510
- }
511
- )->S.castToUnknown
512
- let schema' = schema->withSpec({...emptySpec, scanSort: ["name"]})
513
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
514
- let nameSchema = getPropertyOf(json, "name")
515
- let idSchema = getPropertyOf(json, "id")
516
- expect((
517
- nameSchema
518
- ->Option.flatMap(s => getProperty(s, "x-reventless-scanSort"))
519
- ->Option.flatMap(JSON.Decode.bool),
520
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-scanSort")),
521
- ))->toEqual((Some(true), None))
522
- })
523
-
524
- testSync("emits x-reventless-semantic on the field listed in semantic", () => {
525
- let schema = S.schema(s =>
526
- {
527
- "id": s.matches(S.string),
528
- "total": s.matches(S.float),
529
- }
530
- )->S.castToUnknown
531
- let schema' = schema->withSpec({...emptySpec, semantic: [("total", "currency")]})
532
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
533
- let totalSchema = getPropertyOf(json, "total")
534
- let idSchema = getPropertyOf(json, "id")
535
- expect((
536
- totalSchema
537
- ->Option.flatMap(s => getProperty(s, "x-reventless-semantic"))
538
- ->Option.flatMap(JSON.Decode.string),
539
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-semantic")),
540
- ))->toEqual((Some("currency"), None))
541
- })
542
-
543
- testSync("emits x-reventless-metric {aggregate,label} on the field listed in metric", () => {
544
- let schema = S.schema(s =>
545
- {
546
- "id": s.matches(S.string),
547
- "total": s.matches(S.float),
548
- }
549
- )->S.castToUnknown
550
- let schema' =
551
- schema->withSpec({...emptySpec, metric: [("total", {aggregate: "sum", label: "Revenue"})]})
552
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
553
- let metricObj = getPropertyOf(json, "total")->Option.flatMap(s => getProperty(s, "x-reventless-metric"))
554
- expect((
555
- metricObj->Option.flatMap(m => getProperty(m, "aggregate"))->Option.flatMap(JSON.Decode.string),
556
- metricObj->Option.flatMap(m => getProperty(m, "label"))->Option.flatMap(JSON.Decode.string),
557
- ))->toEqual((Some("sum"), Some("Revenue")))
558
- })
559
-
560
- testSync("omits the metric label when empty (UI derives one from the field name)", () => {
561
- let schema = S.schema(s => {"total": s.matches(S.float)})->S.castToUnknown
562
- let schema' = schema->withSpec({...emptySpec, metric: [("total", {aggregate: "sum", label: ""})]})
563
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
564
- let metricObj = getPropertyOf(json, "total")->Option.flatMap(s => getProperty(s, "x-reventless-metric"))
565
- expect((
566
- metricObj->Option.flatMap(m => getProperty(m, "aggregate"))->Option.flatMap(JSON.Decode.string),
567
- metricObj->Option.flatMap(m => getProperty(m, "label")),
568
- ))->toEqual((Some("sum"), None))
569
- })
570
-
571
- testSync("emits x-reventless-retired {label,showWhenFalse} on the retired field", () => {
572
- let schema = S.schema(s =>
573
- {
574
- "id": s.matches(S.string),
575
- "archived": s.matches(S.bool),
576
- }
577
- )->S.castToUnknown
578
- let schema' =
579
- schema->withSpec({
450
+ testSync(
451
+ "keeps a dropped field out of required",
452
+ () => {
453
+ let schema = S.schema(
454
+ s =>
455
+ {
456
+ "id": s.matches(S.string),
457
+ "eventCollector": s.matches(S.string),
458
+ },
459
+ )->S.castToUnknown
460
+ let json =
461
+ schema
462
+ ->withSpec({...emptySpec, internal: ["eventCollector"]})
463
+ ->SuryToJsonSchema.deriveObjectSchema
464
+ expect(
465
+ json
466
+ ->JSON.Decode.object
467
+ ->Option.flatMap(o => o->Dict.get("required"))
468
+ ->Option.flatMap(JSON.Decode.array)
469
+ ->Option.getOr([])
470
+ ->Array.filterMap(JSON.Decode.string),
471
+ )->toEqual(["id"])
472
+ },
473
+ )
474
+
475
+ testSync(
476
+ "emits x-reventless-summary on field listed in summary",
477
+ () => {
478
+ let schema = S.schema(
479
+ s =>
480
+ {
481
+ "id": s.matches(S.string),
482
+ "pluginName": s.matches(S.string),
483
+ },
484
+ )->S.castToUnknown
485
+ let schema' = schema->withSpec({...emptySpec, summary: ["pluginName"]})
486
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
487
+ let pluginNameSchema = getPropertyOf(json, "pluginName")
488
+ let idSchema = getPropertyOf(json, "id")
489
+ expect((
490
+ pluginNameSchema
491
+ ->Option.flatMap(s => getProperty(s, "x-reventless-summary"))
492
+ ->Option.flatMap(JSON.Decode.bool),
493
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-summary")),
494
+ ))->toEqual((Some(true), None))
495
+ },
496
+ )
497
+
498
+ testSync(
499
+ "emits x-reventless-drillTarget as the slice name",
500
+ () => {
501
+ let schema = S.schema(
502
+ s =>
503
+ {
504
+ "id": s.matches(S.string),
505
+ "components": s.matches(S.string),
506
+ },
507
+ )->S.castToUnknown
508
+ let schema' = schema->withSpec({
509
+ ...emptySpec,
510
+ drillTargets: [("components", "ResourceInventory")],
511
+ })
512
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
513
+ let componentsSchema = getPropertyOf(json, "components")
514
+ let idSchema = getPropertyOf(json, "id")
515
+ expect((
516
+ componentsSchema
517
+ ->Option.flatMap(s => getProperty(s, "x-reventless-drillTarget"))
518
+ ->Option.flatMap(JSON.Decode.string),
519
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-drillTarget")),
520
+ ))->toEqual((Some("ResourceInventory"), None))
521
+ },
522
+ )
523
+
524
+ testSync(
525
+ "emits x-reventless-drillTargetKey as the key path",
526
+ () => {
527
+ let schema = S.schema(
528
+ s =>
529
+ {
530
+ "id": s.matches(S.string),
531
+ "components": s.matches(S.string),
532
+ },
533
+ )->S.castToUnknown
534
+ let schema' = schema->withSpec({
535
+ ...emptySpec,
536
+ drillTargets: [("components", "ResourceInventory")],
537
+ drillTargetKeys: [("components", "kind/name")],
538
+ })
539
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
540
+ let componentsSchema = getPropertyOf(json, "components")
541
+ expect(
542
+ componentsSchema
543
+ ->Option.flatMap(s => getProperty(s, "x-reventless-drillTargetKey"))
544
+ ->Option.flatMap(JSON.Decode.string),
545
+ )->toBe(Some("kind/name"))
546
+ },
547
+ )
548
+
549
+ testSync(
550
+ "does not emit x-reventless-drillTargetKey when no key was supplied",
551
+ () => {
552
+ let schema = S.schema(
553
+ s =>
554
+ {
555
+ "id": s.matches(S.string),
556
+ "components": s.matches(S.string),
557
+ },
558
+ )->S.castToUnknown
559
+ let schema' = schema->withSpec({
560
+ ...emptySpec,
561
+ drillTargets: [("components", "ResourceInventory")],
562
+ })
563
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
564
+ let componentsSchema = getPropertyOf(json, "components")
565
+ expect(
566
+ componentsSchema->Option.flatMap(s => getProperty(s, "x-reventless-drillTargetKey")),
567
+ )->toBe(None)
568
+ },
569
+ )
570
+
571
+ testSync(
572
+ "emits x-reventless-scan on field listed in scan",
573
+ () => {
574
+ let schema = S.schema(
575
+ s =>
576
+ {
577
+ "id": s.matches(S.string),
578
+ "status": s.matches(S.string),
579
+ },
580
+ )->S.castToUnknown
581
+ let schema' = schema->withSpec({...emptySpec, scan: ["status"]})
582
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
583
+ let statusSchema = getPropertyOf(json, "status")
584
+ let idSchema = getPropertyOf(json, "id")
585
+ expect((
586
+ statusSchema
587
+ ->Option.flatMap(s => getProperty(s, "x-reventless-scan"))
588
+ ->Option.flatMap(JSON.Decode.bool),
589
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-scan")),
590
+ ))->toEqual((Some(true), None))
591
+ },
592
+ )
593
+
594
+ testSync(
595
+ "emits x-reventless-scanSort on field listed in scanSort",
596
+ () => {
597
+ let schema = S.schema(
598
+ s =>
599
+ {
600
+ "id": s.matches(S.string),
601
+ "name": s.matches(S.string),
602
+ },
603
+ )->S.castToUnknown
604
+ let schema' = schema->withSpec({...emptySpec, scanSort: ["name"]})
605
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
606
+ let nameSchema = getPropertyOf(json, "name")
607
+ let idSchema = getPropertyOf(json, "id")
608
+ expect((
609
+ nameSchema
610
+ ->Option.flatMap(s => getProperty(s, "x-reventless-scanSort"))
611
+ ->Option.flatMap(JSON.Decode.bool),
612
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-scanSort")),
613
+ ))->toEqual((Some(true), None))
614
+ },
615
+ )
616
+
617
+ testSync(
618
+ "emits x-reventless-semantic on the field listed in semantic",
619
+ () => {
620
+ let schema = S.schema(
621
+ s =>
622
+ {
623
+ "id": s.matches(S.string),
624
+ "total": s.matches(S.float),
625
+ },
626
+ )->S.castToUnknown
627
+ let schema' = schema->withSpec({...emptySpec, semantic: [("total", "currency")]})
628
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
629
+ let totalSchema = getPropertyOf(json, "total")
630
+ let idSchema = getPropertyOf(json, "id")
631
+ expect((
632
+ totalSchema
633
+ ->Option.flatMap(s => getProperty(s, "x-reventless-semantic"))
634
+ ->Option.flatMap(JSON.Decode.string),
635
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-semantic")),
636
+ ))->toEqual((Some("currency"), None))
637
+ },
638
+ )
639
+
640
+ testSync(
641
+ "emits x-reventless-metric {aggregate,label} on the field listed in metric",
642
+ () => {
643
+ let schema = S.schema(
644
+ s =>
645
+ {
646
+ "id": s.matches(S.string),
647
+ "total": s.matches(S.float),
648
+ },
649
+ )->S.castToUnknown
650
+ let schema' = schema->withSpec({
580
651
  ...emptySpec,
581
- retired: Some({field: "archived", label: "Archived", showWhenFalse: true, values: None, namedWhenRetired: false}),
652
+ metric: [("total", {aggregate: "sum", label: "Revenue"})],
582
653
  })
583
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
584
- let retiredObj =
585
- getPropertyOf(json, "archived")->Option.flatMap(s => getProperty(s, "x-reventless-retired"))
586
- expect((
587
- retiredObj->Option.flatMap(r => getProperty(r, "label"))->Option.flatMap(JSON.Decode.string),
588
- retiredObj
589
- ->Option.flatMap(r => getProperty(r, "showWhenFalse"))
590
- ->Option.flatMap(JSON.Decode.bool),
591
- // Only the named field carries it.
592
- getPropertyOf(json, "id")->Option.flatMap(s => getProperty(s, "x-reventless-retired")),
593
- ))->toEqual((Some("Archived"), Some(true), None))
594
- })
654
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
655
+ let metricObj =
656
+ getPropertyOf(json, "total")->Option.flatMap(s => getProperty(s, "x-reventless-metric"))
657
+ expect((
658
+ metricObj
659
+ ->Option.flatMap(m => getProperty(m, "aggregate"))
660
+ ->Option.flatMap(JSON.Decode.string),
661
+ metricObj
662
+ ->Option.flatMap(m => getProperty(m, "label"))
663
+ ->Option.flatMap(JSON.Decode.string),
664
+ ))->toEqual((Some("sum"), Some("Revenue")))
665
+ },
666
+ )
667
+
668
+ testSync(
669
+ "omits the metric label when empty (UI derives one from the field name)",
670
+ () => {
671
+ let schema = S.schema(s => {"total": s.matches(S.float)})->S.castToUnknown
672
+ let schema' =
673
+ schema->withSpec({...emptySpec, metric: [("total", {aggregate: "sum", label: ""})]})
674
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
675
+ let metricObj =
676
+ getPropertyOf(json, "total")->Option.flatMap(s => getProperty(s, "x-reventless-metric"))
677
+ expect((
678
+ metricObj
679
+ ->Option.flatMap(m => getProperty(m, "aggregate"))
680
+ ->Option.flatMap(JSON.Decode.string),
681
+ metricObj->Option.flatMap(m => getProperty(m, "label")),
682
+ ))->toEqual((Some("sum"), None))
683
+ },
684
+ )
685
+
686
+ testSync(
687
+ "emits x-reventless-retired {label,showWhenFalse} on the retired field",
688
+ () => {
689
+ let schema = S.schema(
690
+ s =>
691
+ {
692
+ "id": s.matches(S.string),
693
+ "archived": s.matches(S.bool),
694
+ },
695
+ )->S.castToUnknown
696
+ let schema' = schema->withSpec({
697
+ ...emptySpec,
698
+ retired: Some({
699
+ field: "archived",
700
+ label: "Archived",
701
+ showWhenFalse: true,
702
+ values: None,
703
+ namedWhenRetired: false,
704
+ }),
705
+ })
706
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
707
+ let retiredObj =
708
+ getPropertyOf(json, "archived")->Option.flatMap(
709
+ s => getProperty(s, "x-reventless-retired"),
710
+ )
711
+ expect((
712
+ retiredObj
713
+ ->Option.flatMap(r => getProperty(r, "label"))
714
+ ->Option.flatMap(JSON.Decode.string),
715
+ retiredObj
716
+ ->Option.flatMap(r => getProperty(r, "showWhenFalse"))
717
+ ->Option.flatMap(JSON.Decode.bool),
718
+ // Only the named field carries it.
719
+ getPropertyOf(json, "id")->Option.flatMap(s => getProperty(s, "x-reventless-retired")),
720
+ ))->toEqual((Some("Archived"), Some(true), None))
721
+ },
722
+ )
595
723
 
596
724
  // The state form. `values` is what tells a consumer which of the two forms a
597
725
  // view declared, so it travels on the same key and only when present.
598
726
  let stateFormJson = (~values: array<string>) => {
599
- let schema = S.schema(s =>
600
- {
601
- "customerId": s.matches(S.string),
602
- "accountStatus": s.matches(S.union([S.literal("Active"), S.literal("Deactivated")])),
603
- }
727
+ let schema = S.schema(
728
+ s =>
729
+ {
730
+ "customerId": s.matches(S.string),
731
+ "accountStatus": s.matches(S.union([S.literal("Active"), S.literal("Deactivated")])),
732
+ },
604
733
  )->S.castToUnknown
605
734
  schema
606
735
  ->withSpec({
@@ -626,202 +755,261 @@ describe("SuryToJsonSchema:", () => {
626
755
  ->Option.flatMap(JSON.Decode.array)
627
756
  ->Option.map(a => a->Array.filterMap(JSON.Decode.string))
628
757
 
629
- testSync("carries one retirement state as a one-element set", () => {
630
- expect(
631
- stateFormJson(~values=["Deactivated"])->retiredOn("accountStatus")->stringsAt("values"),
632
- )->toEqual(Some(["Deactivated"]))
633
- })
758
+ testSync(
759
+ "carries one retirement state as a one-element set",
760
+ () => {
761
+ expect(
762
+ stateFormJson(~values=["Deactivated"])->retiredOn("accountStatus")->stringsAt("values"),
763
+ )->toEqual(Some(["Deactivated"]))
764
+ },
765
+ )
634
766
 
635
- testSync("carries every retirement state a lifecycle declares", () => {
636
- expect(
637
- stateFormJson(~values=["Archived", "Discontinued"])
638
- ->retiredOn("accountStatus")
639
- ->stringsAt("values"),
640
- )->toEqual(Some(["Archived", "Discontinued"]))
641
- })
767
+ testSync(
768
+ "carries every retirement state a lifecycle declares",
769
+ () => {
770
+ expect(
771
+ stateFormJson(~values=["Archived", "Discontinued"])
772
+ ->retiredOn("accountStatus")
773
+ ->stringsAt("values"),
774
+ )->toEqual(Some(["Archived", "Discontinued"]))
775
+ },
776
+ )
642
777
 
643
778
  // The transitional key, for one release. A consumer pinned before the set
644
779
  // degrades to nothing at all rather than to a missing badge — and enforcement
645
780
  // never depended on it, so the whole of the lag is cosmetic either way.
646
- testSync("emits the singular value beside a one-element set", () => {
647
- expect(
648
- stateFormJson(~values=["Deactivated"])
649
- ->retiredOn("accountStatus")
650
- ->Option.flatMap(r => getProperty(r, "value"))
651
- ->Option.flatMap(JSON.Decode.string),
652
- )->toEqual(Some("Deactivated"))
653
- })
781
+ testSync(
782
+ "emits the singular value beside a one-element set",
783
+ () => {
784
+ expect(
785
+ stateFormJson(~values=["Deactivated"])
786
+ ->retiredOn("accountStatus")
787
+ ->Option.flatMap(r => getProperty(r, "value"))
788
+ ->Option.flatMap(JSON.Decode.string),
789
+ )->toEqual(Some("Deactivated"))
790
+ },
791
+ )
654
792
 
655
793
  // There is no singular reading of two states, so the shim simply stops. A
656
794
  // consumer that understands only `value` sees no retirement rather than one
657
795
  // of the two picked arbitrarily.
658
- testSync("omits the singular value for a set of two", () => {
659
- expect(
660
- stateFormJson(~values=["Archived", "Discontinued"])
661
- ->retiredOn("accountStatus")
662
- ->Option.flatMap(r => getProperty(r, "value")),
663
- )->toEqual(None)
664
- })
796
+ testSync(
797
+ "omits the singular value for a set of two",
798
+ () => {
799
+ expect(
800
+ stateFormJson(~values=["Archived", "Discontinued"])
801
+ ->retiredOn("accountStatus")
802
+ ->Option.flatMap(r => getProperty(r, "value")),
803
+ )->toEqual(None)
804
+ },
805
+ )
665
806
 
666
807
  // Absent rather than null: a consumer reads the key's absence as "the boolean
667
808
  // form", the same way it reads an absent label as "derive one from the name".
668
- testSync("omits both value and values entirely on the boolean form", () => {
669
- let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
670
- let schema' =
671
- schema->withSpec({
809
+ testSync(
810
+ "omits both value and values entirely on the boolean form",
811
+ () => {
812
+ let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
813
+ let schema' = schema->withSpec({
672
814
  ...emptySpec,
673
- retired: Some({field: "archived", label: "", showWhenFalse: false, values: None, namedWhenRetired: false}),
815
+ retired: Some({
816
+ field: "archived",
817
+ label: "",
818
+ showWhenFalse: false,
819
+ values: None,
820
+ namedWhenRetired: false,
821
+ }),
674
822
  })
675
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
676
- let retiredObj = json->retiredOn("archived")
677
- expect((
678
- retiredObj->Option.flatMap(r => getProperty(r, "value")),
679
- retiredObj->Option.flatMap(r => getProperty(r, "values")),
680
- ))->toEqual((None, None))
681
- })
823
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
824
+ let retiredObj = json->retiredOn("archived")
825
+ expect((
826
+ retiredObj->Option.flatMap(r => getProperty(r, "value")),
827
+ retiredObj->Option.flatMap(r => getProperty(r, "values")),
828
+ ))->toEqual((None, None))
829
+ },
830
+ )
682
831
 
683
832
  // The opt-in that lets a reference name a withheld row. Travels only when
684
833
  // true, on the omit-the-default rule the keys around it follow: false is what
685
834
  // every record said before it existed, and writing it would put a key on
686
835
  // every retirement to report that nothing changed.
687
- testSync("carries namedWhenRetired only when the record opted in", () => {
688
- let build = (~named: bool) => {
689
- let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
690
- schema
691
- ->withSpec({
836
+ testSync(
837
+ "carries namedWhenRetired only when the record opted in",
838
+ () => {
839
+ let build = (~named: bool) => {
840
+ let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
841
+ schema
842
+ ->withSpec({
843
+ ...emptySpec,
844
+ retired: Some({
845
+ field: "archived",
846
+ label: "",
847
+ showWhenFalse: false,
848
+ values: None,
849
+ namedWhenRetired: named,
850
+ }),
851
+ })
852
+ ->SuryToJsonSchema.deriveObjectSchema
853
+ ->retiredOn("archived")
854
+ ->Option.flatMap(r => getProperty(r, "namedWhenRetired"))
855
+ ->Option.flatMap(JSON.Decode.bool)
856
+ }
857
+ expect((build(~named=true), build(~named=false)))->toEqual((Some(true), None))
858
+ },
859
+ )
860
+
861
+ testSync(
862
+ "omits the retired label when empty but always states showWhenFalse",
863
+ () => {
864
+ let schema = S.schema(s => {"deactivated": s.matches(S.bool)})->S.castToUnknown
865
+ let schema' = schema->withSpec({
692
866
  ...emptySpec,
693
867
  retired: Some({
694
- field: "archived",
868
+ field: "deactivated",
695
869
  label: "",
696
870
  showWhenFalse: false,
697
871
  values: None,
698
- namedWhenRetired: named,
872
+ namedWhenRetired: false,
699
873
  }),
700
874
  })
701
- ->SuryToJsonSchema.deriveObjectSchema
702
- ->retiredOn("archived")
703
- ->Option.flatMap(r => getProperty(r, "namedWhenRetired"))
704
- ->Option.flatMap(JSON.Decode.bool)
705
- }
706
- expect((build(~named=true), build(~named=false)))->toEqual((Some(true), None))
707
- })
875
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
876
+ let retiredObj =
877
+ getPropertyOf(json, "deactivated")->Option.flatMap(
878
+ s => getProperty(s, "x-reventless-retired"),
879
+ )
880
+ expect((
881
+ retiredObj->Option.flatMap(r => getProperty(r, "label")),
882
+ retiredObj
883
+ ->Option.flatMap(r => getProperty(r, "showWhenFalse"))
884
+ ->Option.flatMap(JSON.Decode.bool),
885
+ ))->toEqual((None, Some(false)))
886
+ },
887
+ )
888
+
889
+ testSync(
890
+ "emits nothing for a schema with no retired annotation",
891
+ () => {
892
+ let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
893
+ let json = SuryToJsonSchema.deriveObjectSchema(schema->withSpec(emptySpec))
894
+ expect(
895
+ getPropertyOf(json, "archived")->Option.flatMap(
896
+ s => getProperty(s, "x-reventless-retired"),
897
+ ),
898
+ )->toBe(None)
899
+ },
900
+ )
901
+
902
+ testSync(
903
+ "emits x-reventless-group-by on the field named by groupBy",
904
+ () => {
905
+ let schema = S.schema(
906
+ s =>
907
+ {
908
+ "id": s.matches(S.string),
909
+ "kind": s.matches(S.string),
910
+ },
911
+ )->S.castToUnknown
912
+ let schema' = schema->withSpec({...emptySpec, groupBy: Some("kind")})
913
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
914
+ let kindSchema = getPropertyOf(json, "kind")
915
+ let idSchema = getPropertyOf(json, "id")
916
+ expect((
917
+ kindSchema
918
+ ->Option.flatMap(s => getProperty(s, "x-reventless-group-by"))
919
+ ->Option.flatMap(JSON.Decode.bool),
920
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-group-by")),
921
+ ))->toEqual((Some(true), None))
922
+ },
923
+ )
708
924
 
709
- testSync("omits the retired label when empty but always states showWhenFalse", () => {
710
- let schema = S.schema(s => {"deactivated": s.matches(S.bool)})->S.castToUnknown
711
- let schema' =
712
- schema->withSpec({
925
+ testSync(
926
+ "emits x-reventless-collapsed on field listed in collapsed",
927
+ () => {
928
+ let schema = S.schema(
929
+ s =>
930
+ {
931
+ "id": s.matches(S.string),
932
+ "primaryResource": s.matches(S.string),
933
+ },
934
+ )->S.castToUnknown
935
+ let schema' = schema->withSpec({...emptySpec, collapsed: ["primaryResource"]})
936
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
937
+ let primarySchema = getPropertyOf(json, "primaryResource")
938
+ let idSchema = getPropertyOf(json, "id")
939
+ expect((
940
+ primarySchema
941
+ ->Option.flatMap(s => getProperty(s, "x-reventless-collapsed"))
942
+ ->Option.flatMap(JSON.Decode.bool),
943
+ idSchema->Option.flatMap(s => getProperty(s, "x-reventless-collapsed")),
944
+ ))->toEqual((Some(true), None))
945
+ },
946
+ )
947
+
948
+ testSync(
949
+ "emits x-reventless-visibility at top level when visibility is Internal",
950
+ () => {
951
+ let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
952
+ let schema' = schema->withSpec({...emptySpec, visibility: Some("Internal")})
953
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
954
+ expect(
955
+ getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
956
+ )->toBe(Some("Internal"))
957
+ },
958
+ )
959
+
960
+ testSync(
961
+ "omits x-reventless-visibility when visibility is None (default Public)",
962
+ () => {
963
+ let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
964
+ let schema' = schema->withSpec({...emptySpec, visibility: None})
965
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
966
+ expect(getProperty(json, "x-reventless-visibility"))->toBe(None)
967
+ },
968
+ )
969
+
970
+ testSync(
971
+ "emits x-reventless-live at top level when live is declared",
972
+ () => {
973
+ let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
974
+ let offJson = SuryToJsonSchema.deriveObjectSchema(
975
+ schema->withSpec({...emptySpec, live: Some(false)}),
976
+ )
977
+ let onJson = SuryToJsonSchema.deriveObjectSchema(
978
+ schema->withSpec({...emptySpec, live: Some(true)}),
979
+ )
980
+ expect((
981
+ getProperty(offJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
982
+ getProperty(onJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
983
+ ))->toEqual((Some(false), Some(true)))
984
+ },
985
+ )
986
+
987
+ testSync(
988
+ "omits x-reventless-live when live is None (no annotation)",
989
+ () => {
990
+ let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
991
+ let schema' = schema->withSpec({...emptySpec, live: None})
992
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
993
+ expect(getProperty(json, "x-reventless-live"))->toBe(None)
994
+ },
995
+ )
996
+
997
+ testSync(
998
+ "emits x-reventless-live and x-reventless-visibility together",
999
+ () => {
1000
+ let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
1001
+ let schema' = schema->withSpec({
713
1002
  ...emptySpec,
714
- retired: Some({field: "deactivated", label: "", showWhenFalse: false, values: None, namedWhenRetired: false}),
1003
+ visibility: Some("Internal"),
1004
+ live: Some(false),
715
1005
  })
716
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
717
- let retiredObj =
718
- getPropertyOf(json, "deactivated")->Option.flatMap(s =>
719
- getProperty(s, "x-reventless-retired")
720
- )
721
- expect((
722
- retiredObj->Option.flatMap(r => getProperty(r, "label")),
723
- retiredObj
724
- ->Option.flatMap(r => getProperty(r, "showWhenFalse"))
725
- ->Option.flatMap(JSON.Decode.bool),
726
- ))->toEqual((None, Some(false)))
727
- })
728
-
729
- testSync("emits nothing for a schema with no retired annotation", () => {
730
- let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
731
- let json = SuryToJsonSchema.deriveObjectSchema(schema->withSpec(emptySpec))
732
- expect(
733
- getPropertyOf(json, "archived")->Option.flatMap(s => getProperty(s, "x-reventless-retired")),
734
- )->toBe(None)
735
- })
736
-
737
- testSync("emits x-reventless-group-by on the field named by groupBy", () => {
738
- let schema = S.schema(s =>
739
- {
740
- "id": s.matches(S.string),
741
- "kind": s.matches(S.string),
742
- }
743
- )->S.castToUnknown
744
- let schema' = schema->withSpec({...emptySpec, groupBy: Some("kind")})
745
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
746
- let kindSchema = getPropertyOf(json, "kind")
747
- let idSchema = getPropertyOf(json, "id")
748
- expect((
749
- kindSchema
750
- ->Option.flatMap(s => getProperty(s, "x-reventless-group-by"))
751
- ->Option.flatMap(JSON.Decode.bool),
752
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-group-by")),
753
- ))->toEqual((Some(true), None))
754
- })
755
-
756
- testSync("emits x-reventless-collapsed on field listed in collapsed", () => {
757
- let schema = S.schema(s =>
758
- {
759
- "id": s.matches(S.string),
760
- "primaryResource": s.matches(S.string),
761
- }
762
- )->S.castToUnknown
763
- let schema' = schema->withSpec({...emptySpec, collapsed: ["primaryResource"]})
764
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
765
- let primarySchema = getPropertyOf(json, "primaryResource")
766
- let idSchema = getPropertyOf(json, "id")
767
- expect((
768
- primarySchema
769
- ->Option.flatMap(s => getProperty(s, "x-reventless-collapsed"))
770
- ->Option.flatMap(JSON.Decode.bool),
771
- idSchema->Option.flatMap(s => getProperty(s, "x-reventless-collapsed")),
772
- ))->toEqual((Some(true), None))
773
- })
774
-
775
- testSync("emits x-reventless-visibility at top level when visibility is Internal", () => {
776
- let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
777
- let schema' = schema->withSpec({...emptySpec, visibility: Some("Internal")})
778
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
779
- expect(
780
- getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
781
- )->toBe(Some("Internal"))
782
- })
783
-
784
- testSync("omits x-reventless-visibility when visibility is None (default Public)", () => {
785
- let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
786
- let schema' = schema->withSpec({...emptySpec, visibility: None})
787
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
788
- expect(getProperty(json, "x-reventless-visibility"))->toBe(None)
789
- })
790
-
791
- testSync("emits x-reventless-live at top level when live is declared", () => {
792
- let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
793
- let offJson = SuryToJsonSchema.deriveObjectSchema(
794
- schema->withSpec({...emptySpec, live: Some(false)}),
795
- )
796
- let onJson = SuryToJsonSchema.deriveObjectSchema(
797
- schema->withSpec({...emptySpec, live: Some(true)}),
798
- )
799
- expect((
800
- getProperty(offJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
801
- getProperty(onJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
802
- ))->toEqual((Some(false), Some(true)))
803
- })
804
-
805
- testSync("omits x-reventless-live when live is None (no annotation)", () => {
806
- let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
807
- let schema' = schema->withSpec({...emptySpec, live: None})
808
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
809
- expect(getProperty(json, "x-reventless-live"))->toBe(None)
810
- })
811
-
812
- testSync("emits x-reventless-live and x-reventless-visibility together", () => {
813
- let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
814
- let schema' = schema->withSpec({
815
- ...emptySpec,
816
- visibility: Some("Internal"),
817
- live: Some(false),
818
- })
819
- let json = SuryToJsonSchema.deriveObjectSchema(schema')
820
- expect((
821
- getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
822
- getProperty(json, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
823
- ))->toEqual((Some("Internal"), Some(false)))
824
- })
1006
+ let json = SuryToJsonSchema.deriveObjectSchema(schema')
1007
+ expect((
1008
+ getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
1009
+ getProperty(json, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
1010
+ ))->toEqual((Some("Internal"), Some(false)))
1011
+ },
1012
+ )
825
1013
  })
826
1014
 
827
1015
  // Parity sanity-check: deriveObjectSchema and the legacy S.toJSONSchema must
@@ -840,50 +1028,59 @@ describe("SuryToJsonSchema:", () => {
840
1028
  | None => []
841
1029
  }
842
1030
 
843
- let sortStrings = (xs: array<string>): array<string> =>
844
- xs->Array.toSorted(String.compare)
1031
+ let sortStrings = (xs: array<string>): array<string> => xs->Array.toSorted(String.compare)
1032
+
1033
+ testSync(
1034
+ "properties keyset matches between deriveObjectSchema and S.toJSONSchema",
1035
+ () => {
1036
+ let schema = S.schema(
1037
+ s =>
1038
+ {
1039
+ "id": s.matches(S.string),
1040
+ "name": s.matches(S.string),
1041
+ "count": s.matches(S.int),
1042
+ },
1043
+ )->S.castToUnknown
1044
+ let derived = SuryToJsonSchema.deriveObjectSchema(schema)
1045
+ let native = (schema->S.toJSONSchema->Obj.magic: JSON.t)
1046
+ expect(derived->getKeys->sortStrings)->toEqual(native->getKeys->sortStrings)
1047
+ },
1048
+ )
845
1049
 
846
- testSync("properties keyset matches between deriveObjectSchema and S.toJSONSchema", () => {
847
- let schema = S.schema(s =>
848
- {
849
- "id": s.matches(S.string),
850
- "name": s.matches(S.string),
851
- "count": s.matches(S.int),
852
- }
853
- )->S.castToUnknown
854
- let derived = SuryToJsonSchema.deriveObjectSchema(schema)
855
- let native = (schema->S.toJSONSchema->Obj.magic: JSON.t)
856
- expect(derived->getKeys->sortStrings)->toEqual(native->getKeys->sortStrings)
857
- })
1050
+ testSync(
1051
+ "required array matches between deriveObjectSchema and S.toJSONSchema",
1052
+ () => {
1053
+ let schema = S.schema(
1054
+ s =>
1055
+ {
1056
+ "id": s.matches(S.string),
1057
+ "name": s.matches(S.string),
1058
+ },
1059
+ )->S.castToUnknown
1060
+ let derived = SuryToJsonSchema.deriveObjectSchema(schema)
1061
+ let native = (schema->S.toJSONSchema->Obj.magic: JSON.t)
1062
+ expect(derived->getRequired->sortStrings)->toEqual(native->getRequired->sortStrings)
1063
+ },
1064
+ )
858
1065
 
859
- testSync("required array matches between deriveObjectSchema and S.toJSONSchema", () => {
860
- let schema = S.schema(s =>
861
- {
862
- "id": s.matches(S.string),
863
- "name": s.matches(S.string),
864
- }
865
- )->S.castToUnknown
866
- let derived = SuryToJsonSchema.deriveObjectSchema(schema)
867
- let native = (schema->S.toJSONSchema->Obj.magic: JSON.t)
868
- expect(derived->getRequired->sortStrings)->toEqual(native->getRequired->sortStrings)
869
- })
870
-
871
- testSync("S.toJSONSchema does NOT emit x-reventless-* keys even when metadata is set", () => {
872
- let withSpec = (schema, spec) =>
873
- schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
874
- let schema = S.schema(s =>
875
- {
876
- "entityId": s.matches(S.string),
877
- "name": s.matches(S.string),
878
- }
879
- )->S.castToUnknown
880
- let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
881
- let native = (schema'->S.toJSONSchema->Obj.magic: JSON.t)
882
- let entityIdSchema = getPropertyOf(native, "entityId")
883
- expect(
884
- entityIdSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")),
885
- )->toBe(None)
886
- })
1066
+ testSync(
1067
+ "S.toJSONSchema does NOT emit x-reventless-* keys even when metadata is set",
1068
+ () => {
1069
+ let withSpec = (schema, spec) =>
1070
+ schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
1071
+ let schema = S.schema(
1072
+ s =>
1073
+ {
1074
+ "entityId": s.matches(S.string),
1075
+ "name": s.matches(S.string),
1076
+ },
1077
+ )->S.castToUnknown
1078
+ let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
1079
+ let native = (schema'->S.toJSONSchema->Obj.magic: JSON.t)
1080
+ let entityIdSchema = getPropertyOf(native, "entityId")
1081
+ expect(entityIdSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")))->toBe(None)
1082
+ },
1083
+ )
887
1084
  })
888
1085
 
889
1086
  // Reference-ness and DCB-tagged-ness are separate facts that happen to
@@ -896,51 +1093,65 @@ describe("SuryToJsonSchema:", () => {
896
1093
  let formatOf = (schema: JSON.t, fieldName: string): option<JSON.t> =>
897
1094
  getPropertyOf(schema, fieldName)->Option.flatMap(s => getProperty(s, "format"))
898
1095
 
899
- testSync("a DCB-tagged field with no reference is still an entity id", () => {
900
- let schema = S.schema(s =>
901
- {
902
- "orderId": s.matches(Reventless.DcbTag.string),
903
- }
904
- )->S.castToUnknown
905
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
906
- expect(formatOf(json, "orderId"))->toEqual(Some(JSON.Encode.string("uuid")))
907
- expect(Reventless.Reference.getTarget(schema))->toBe(None)
908
- })
909
-
910
- testSync("a reference without a DCB tag is still an entity id", () => {
911
- let field = Reventless.Reference.toWithoutDcbTag("Customer")
912
- let schema = S.schema(s => {"customerId": s.matches(field)})->S.castToUnknown
913
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
914
- expect(formatOf(json, "customerId"))->toEqual(Some(JSON.Encode.string("uuid")))
915
- expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(false)
916
- })
917
-
918
- testSync("Reference.to_ carries both facts", () => {
919
- let field = Reventless.Reference.to_("Customer")
920
- expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(true)
921
- expect(
922
- Reventless.Reference.getTarget(field->S.castToUnknown)->Option.map(t => t.entity),
923
- )->toEqual(Some("Customer"))
924
- })
925
-
926
- testSync("neither reference nor tag emits x-reventless-semantic on the wire", () => {
927
- // `dateTime` and `reference` predate the generic marker and keep emitting
928
- // `format`; adding the semantic key for them would change a published
929
- // contract. Only semantics without a dedicated shape surface as the key.
930
- let schema = S.schema(s =>
931
- {
932
- "customerId": s.matches(Reventless.Reference.to_("Customer")),
933
- "placedAt": s.matches(Reventless.DateTime.schema),
934
- }
935
- )->S.castToUnknown
936
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
937
- let semanticOf = fieldName =>
938
- getPropertyOf(json, fieldName)->Option.flatMap(s =>
939
- getProperty(s, "x-reventless-semantic")
940
- )
941
- expect(semanticOf("customerId"))->toBe(None)
942
- expect(semanticOf("placedAt"))->toBe(None)
943
- })
1096
+ testSync(
1097
+ "a DCB-tagged field with no reference is still an entity id",
1098
+ () => {
1099
+ let schema = S.schema(
1100
+ s =>
1101
+ {
1102
+ "orderId": s.matches(Reventless.DcbTag.string),
1103
+ },
1104
+ )->S.castToUnknown
1105
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1106
+ expect(formatOf(json, "orderId"))->toEqual(Some(JSON.Encode.string("uuid")))
1107
+ expect(Reventless.Reference.getTarget(schema))->toBe(None)
1108
+ },
1109
+ )
1110
+
1111
+ testSync(
1112
+ "a reference without a DCB tag is still an entity id",
1113
+ () => {
1114
+ let field = Reventless.Reference.toWithoutDcbTag("Customer")
1115
+ let schema = S.schema(s => {"customerId": s.matches(field)})->S.castToUnknown
1116
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1117
+ expect(formatOf(json, "customerId"))->toEqual(Some(JSON.Encode.string("uuid")))
1118
+ expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(false)
1119
+ },
1120
+ )
1121
+
1122
+ testSync(
1123
+ "Reference.to_ carries both facts",
1124
+ () => {
1125
+ let field = Reventless.Reference.to_("Customer")
1126
+ expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(true)
1127
+ expect(
1128
+ Reventless.Reference.getTarget(field->S.castToUnknown)->Option.map(t => t.entity),
1129
+ )->toEqual(Some("Customer"))
1130
+ },
1131
+ )
1132
+
1133
+ testSync(
1134
+ "neither reference nor tag emits x-reventless-semantic on the wire",
1135
+ () => {
1136
+ // `dateTime` and `reference` predate the generic marker and keep emitting
1137
+ // `format`; adding the semantic key for them would change a published
1138
+ // contract. Only semantics without a dedicated shape surface as the key.
1139
+ let schema = S.schema(
1140
+ s =>
1141
+ {
1142
+ "customerId": s.matches(Reventless.Reference.to_("Customer")),
1143
+ "placedAt": s.matches(Reventless.DateTime.schema),
1144
+ },
1145
+ )->S.castToUnknown
1146
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1147
+ let semanticOf = fieldName =>
1148
+ getPropertyOf(json, fieldName)->Option.flatMap(
1149
+ s => getProperty(s, "x-reventless-semantic"),
1150
+ )
1151
+ expect(semanticOf("customerId"))->toBe(None)
1152
+ expect(semanticOf("placedAt"))->toBe(None)
1153
+ },
1154
+ )
944
1155
  })
945
1156
 
946
1157
  // The reason the generic marker exists. `x-reventless-semantic` used to be
@@ -952,83 +1163,108 @@ describe("SuryToJsonSchema:", () => {
952
1163
  let semanticOf = (json, fieldName) =>
953
1164
  getPropertyOf(json, fieldName)->Option.flatMap(s => getProperty(s, "x-reventless-semantic"))
954
1165
 
955
- testSync("a storage-ref command field emits the semantic id", () => {
956
- let schema = S.schema(s =>
957
- {
958
- "productId": s.matches(S.string),
959
- "imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
960
- }
961
- )->S.castToUnknown
962
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
963
- expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
964
- expect(semanticOf(json, "productId"))->toBe(None)
965
- })
1166
+ testSync(
1167
+ "a storage-ref command field emits the semantic id",
1168
+ () => {
1169
+ let schema = S.schema(
1170
+ s =>
1171
+ {
1172
+ "productId": s.matches(S.string),
1173
+ "imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
1174
+ },
1175
+ )->S.castToUnknown
1176
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1177
+ expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
1178
+ expect(semanticOf(json, "productId"))->toBe(None)
1179
+ },
1180
+ )
966
1181
 
967
- testSync("it carries the store identity and marks the type as its source", () => {
968
- let schema = S.schema(s =>
969
- {
970
- "imageUrl": s.matches(
971
- Reventless.StorageRef.forStore(~plugin="catalog", ~store="productImages"),
972
- ),
973
- }
974
- )->S.castToUnknown
975
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
976
- let field = getPropertyOf(json, "imageUrl")
977
- expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-source")))->toEqual(
978
- Some(JSON.Encode.string("type")),
979
- )
980
- expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target")))->toEqual(
981
- Some(
982
- JSON.Encode.object(
983
- Dict.fromArray([
984
- ("store", JSON.Encode.string("productImages")),
985
- ("plugin", JSON.Encode.string("catalog")),
986
- ]),
1182
+ testSync(
1183
+ "it carries the store identity and marks the type as its source",
1184
+ () => {
1185
+ let schema = S.schema(
1186
+ s =>
1187
+ {
1188
+ "imageUrl": s.matches(
1189
+ Reventless.StorageRef.forStore(~plugin="catalog", ~store="productImages"),
1190
+ ),
1191
+ },
1192
+ )->S.castToUnknown
1193
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1194
+ let field = getPropertyOf(json, "imageUrl")
1195
+ expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-source")))->toEqual(
1196
+ Some(JSON.Encode.string("type")),
1197
+ )
1198
+ expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target")))->toEqual(
1199
+ Some(
1200
+ JSON.Encode.object(
1201
+ Dict.fromArray([
1202
+ ("store", JSON.Encode.string("productImages")),
1203
+ ("plugin", JSON.Encode.string("catalog")),
1204
+ ]),
1205
+ ),
987
1206
  ),
988
- ),
989
- )
990
- })
991
-
992
- testSync("the underlying string shape is unchanged", () => {
993
- // The marker refines an existing `string` field; nothing about what is
994
- // stored changes, which is why it can be retrofitted onto a live schema.
995
- let schema = S.schema(s =>
996
- {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))}
997
- )->S.castToUnknown
998
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
999
- expect(
1000
- getPropertyOf(json, "imageUrl")->Option.flatMap(s => getProperty(s, "type")),
1001
- )->toEqual(Some(JSON.Encode.string("string")))
1002
- })
1207
+ )
1208
+ },
1209
+ )
1210
+
1211
+ testSync(
1212
+ "the underlying string shape is unchanged",
1213
+ () => {
1214
+ // The marker refines an existing `string` field; nothing about what is
1215
+ // stored changes, which is why it can be retrofitted onto a live schema.
1216
+ let schema =
1217
+ S.schema(
1218
+ s => {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))},
1219
+ )->S.castToUnknown
1220
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1221
+ expect(
1222
+ getPropertyOf(json, "imageUrl")->Option.flatMap(s => getProperty(s, "type")),
1223
+ )->toEqual(Some(JSON.Encode.string("string")))
1224
+ },
1225
+ )
1003
1226
 
1004
1227
  // An optional field's marker sits inside the wrapper sury-ppx builds around
1005
1228
  // it, so this used to emit a plain nullable string: the field was a storage
1006
1229
  // ref in the source and an untyped string on the wire. The semantic now
1007
1230
  // survives, and it rides *beside* the nullable shape rather than replacing
1008
1231
  // it — a reader still learns the value may be absent.
1009
- testSync("an optional storage-ref field keeps both its semantic and its nullability", () => {
1010
- let schema = S.schema(s =>
1011
- {"imageUrl": s.matches(S.option(Reventless.StorageRef.forStore(~store="productImages")))}
1012
- )->S.castToUnknown
1013
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
1014
- expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
1015
- expect(
1016
- getPropertyOf(json, "imageUrl")->Option.flatMap(s => getProperty(s, "oneOf"))->Option.isSome,
1017
- )->toBe(true)
1018
- })
1019
-
1020
- testSync("a type-carried semantic beats an annotation naming the same field", () => {
1021
- let withSpec = (schema, spec) =>
1022
- schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
1023
- let schema =
1024
- S.schema(s =>
1025
- {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))}
1026
- )
1027
- ->S.castToUnknown
1028
- ->withSpec({...emptySpec, semantic: [("imageUrl", "image")]})
1029
- let json = SuryToJsonSchema.deriveObjectSchema(schema)
1030
- expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
1031
- })
1232
+ testSync(
1233
+ "an optional storage-ref field keeps both its semantic and its nullability",
1234
+ () => {
1235
+ let schema = S.schema(
1236
+ s =>
1237
+ {
1238
+ "imageUrl": s.matches(
1239
+ S.option(Reventless.StorageRef.forStore(~store="productImages")),
1240
+ ),
1241
+ },
1242
+ )->S.castToUnknown
1243
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1244
+ expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
1245
+ expect(
1246
+ getPropertyOf(json, "imageUrl")
1247
+ ->Option.flatMap(s => getProperty(s, "oneOf"))
1248
+ ->Option.isSome,
1249
+ )->toBe(true)
1250
+ },
1251
+ )
1252
+
1253
+ testSync(
1254
+ "a type-carried semantic beats an annotation naming the same field",
1255
+ () => {
1256
+ let withSpec = (schema, spec) =>
1257
+ schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
1258
+ let schema =
1259
+ S.schema(
1260
+ s => {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))},
1261
+ )
1262
+ ->S.castToUnknown
1263
+ ->withSpec({...emptySpec, semantic: [("imageUrl", "image")]})
1264
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
1265
+ expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
1266
+ },
1267
+ )
1032
1268
  })
1033
1269
 
1034
1270
  // A selection, not an input. The whole point of the id is that it travels the
@@ -1036,83 +1272,93 @@ describe("SuryToJsonSchema:", () => {
1036
1272
  // cannot: the candidates are on the row, so nothing is provisioned for it.
1037
1273
  describe("a member-reference field:", () => {
1038
1274
  let targetOf = (json, name) =>
1039
- getPropertyOf(json, name)->Option.flatMap(s =>
1040
- getProperty(s, "x-reventless-semantic-target")
1041
- )
1042
-
1043
- testSync("carries the collection, the view holding it, and what a member is", () => {
1044
- let json = SuryToJsonSchema.deriveObjectSchema(
1045
- S.schema(s =>
1046
- {
1047
- "productImage": s.matches(
1048
- Reventless.MemberRef.of_(
1049
- ~view="Products",
1050
- ~content=Reventless.Semantic.Id.imageRef,
1051
- ~field="productImages",
1052
- ),
1275
+ getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target"))
1276
+
1277
+ testSync(
1278
+ "carries the collection, the view holding it, and what a member is",
1279
+ () => {
1280
+ let json = SuryToJsonSchema.deriveObjectSchema(
1281
+ S.schema(
1282
+ s =>
1283
+ {
1284
+ "productImage": s.matches(
1285
+ Reventless.MemberRef.of_(
1286
+ ~view="Products",
1287
+ ~content=Reventless.Semantic.Id.imageRef,
1288
+ ~field="productImages",
1289
+ ),
1290
+ ),
1291
+ },
1292
+ )->S.castToUnknown,
1293
+ )
1294
+ expect(
1295
+ getPropertyOf(json, "productImage")->Option.flatMap(
1296
+ s => getProperty(s, "x-reventless-semantic"),
1297
+ ),
1298
+ )->toEqual(Some(JSON.Encode.string("memberRef")))
1299
+ expect(targetOf(json, "productImage"))->toEqual(
1300
+ Some(
1301
+ JSON.Encode.object(
1302
+ Dict.fromArray([
1303
+ ("field", JSON.Encode.string("productImages")),
1304
+ ("view", JSON.Encode.string("Products")),
1305
+ ("content", JSON.Encode.string("imageRef")),
1306
+ ]),
1053
1307
  ),
1054
- }
1055
- )->S.castToUnknown,
1056
- )
1057
- expect(
1058
- getPropertyOf(json, "productImage")->Option.flatMap(s =>
1059
- getProperty(s, "x-reventless-semantic")
1060
- ),
1061
- )->toEqual(Some(JSON.Encode.string("memberRef")))
1062
- expect(targetOf(json, "productImage"))->toEqual(
1063
- Some(
1064
- JSON.Encode.object(
1065
- Dict.fromArray([
1066
- ("field", JSON.Encode.string("productImages")),
1067
- ("view", JSON.Encode.string("Products")),
1068
- ("content", JSON.Encode.string("imageRef")),
1069
- ]),
1070
1308
  ),
1071
- ),
1072
- )
1073
- })
1309
+ )
1310
+ },
1311
+ )
1074
1312
 
1075
1313
  // The second position: a declaration on a view's own state, where the
1076
1314
  // collection is on the same record and naming a view would invite the reader
1077
1315
  // to think another one could be meant.
1078
- testSync("omits the view where the collection is on this very record", () =>
1079
- expect(
1080
- targetOf(
1081
- SuryToJsonSchema.deriveObjectSchema(
1082
- S.schema(s =>
1083
- {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))}
1084
- )->S.castToUnknown,
1316
+ testSync(
1317
+ "omits the view where the collection is on this very record",
1318
+ () =>
1319
+ expect(
1320
+ targetOf(
1321
+ SuryToJsonSchema.deriveObjectSchema(
1322
+ S.schema(
1323
+ s => {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))},
1324
+ )->S.castToUnknown,
1325
+ ),
1326
+ "productImage",
1327
+ ),
1328
+ )->toEqual(
1329
+ Some(
1330
+ JSON.Encode.object(Dict.fromArray([("field", JSON.Encode.string("productImages"))])),
1085
1331
  ),
1086
- "productImage",
1087
1332
  ),
1088
- )->toEqual(
1089
- Some(JSON.Encode.object(Dict.fromArray([("field", JSON.Encode.string("productImages"))]))),
1090
- )
1091
1333
  )
1092
1334
 
1093
1335
  // The assertion the whole design rests on, and the one a reader will want to
1094
1336
  // see rather than infer: a `memberRef` field declares NO store, so the
1095
1337
  // provisioning walk passes it by and no upload endpoint is bound. This is
1096
1338
  // exactly what `imageRef` gets today, reached through a different payload.
1097
- testSync("declares no store, so nothing is provisioned for it", () =>
1098
- expect(
1099
- Reventless.StorageRef.getFieldStore(
1100
- Reventless.MemberRef.of_(~view="Products", ~field="productImages"),
1101
- ),
1102
- )->toBe(None)
1339
+ testSync(
1340
+ "declares no store, so nothing is provisioned for it",
1341
+ () =>
1342
+ expect(
1343
+ Reventless.StorageRef.getFieldStore(
1344
+ Reventless.MemberRef.of_(~view="Products", ~field="productImages"),
1345
+ ),
1346
+ )->toBe(None),
1103
1347
  )
1104
1348
 
1105
- testSync("stays a plain string on the wire", () =>
1106
- expect(
1107
- getPropertyOf(
1108
- SuryToJsonSchema.deriveObjectSchema(
1109
- S.schema(s =>
1110
- {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))}
1111
- )->S.castToUnknown,
1112
- ),
1113
- "productImage",
1114
- )->Option.flatMap(s => getProperty(s, "type")),
1115
- )->toEqual(Some(JSON.Encode.string("string")))
1349
+ testSync(
1350
+ "stays a plain string on the wire",
1351
+ () =>
1352
+ expect(
1353
+ getPropertyOf(
1354
+ SuryToJsonSchema.deriveObjectSchema(
1355
+ S.schema(
1356
+ s => {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))},
1357
+ )->S.castToUnknown,
1358
+ ),
1359
+ "productImage",
1360
+ )->Option.flatMap(s => getProperty(s, "type")),
1361
+ )->toEqual(Some(JSON.Encode.string("string"))),
1116
1362
  )
1117
1363
  })
1118
1364
 
@@ -1126,35 +1372,42 @@ describe("SuryToJsonSchema:", () => {
1126
1372
  let keyOf = (json, name, key) => fieldOf(json, name)->Option.flatMap(s => getProperty(s, key))
1127
1373
 
1128
1374
  let json = SuryToJsonSchema.deriveObjectSchema(
1129
- S.schema(s =>
1130
- {
1131
- "contactEmail": s.matches(Reventless.Email.schema),
1132
- "taxRate": s.matches(Reventless.Percent.schema),
1133
- }
1375
+ S.schema(
1376
+ s =>
1377
+ {
1378
+ "contactEmail": s.matches(Reventless.Email.schema),
1379
+ "taxRate": s.matches(Reventless.Percent.schema),
1380
+ },
1134
1381
  )->S.castToUnknown,
1135
1382
  )
1136
1383
 
1137
- testSync("a string scalar stays a string and carries its id", () =>
1138
- expect((
1139
- keyOf(json, "contactEmail", "type"),
1140
- keyOf(json, "contactEmail", "x-reventless-semantic"),
1141
- ))->toEqual((Some(JSON.Encode.string("string")), Some(JSON.Encode.string("email"))))
1384
+ testSync(
1385
+ "a string scalar stays a string and carries its id",
1386
+ () =>
1387
+ expect((
1388
+ keyOf(json, "contactEmail", "type"),
1389
+ keyOf(json, "contactEmail", "x-reventless-semantic"),
1390
+ ))->toEqual((Some(JSON.Encode.string("string")), Some(JSON.Encode.string("email")))),
1142
1391
  )
1143
1392
 
1144
- testSync("a numeric scalar stays a number and carries its id", () =>
1145
- expect((
1146
- keyOf(json, "taxRate", "type"),
1147
- keyOf(json, "taxRate", "x-reventless-semantic"),
1148
- ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("percent"))))
1393
+ testSync(
1394
+ "a numeric scalar stays a number and carries its id",
1395
+ () =>
1396
+ expect((
1397
+ keyOf(json, "taxRate", "type"),
1398
+ keyOf(json, "taxRate", "x-reventless-semantic"),
1399
+ ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("percent")))),
1149
1400
  )
1150
1401
 
1151
1402
  // The provenance half: a typed field outranks an annotated one, and this is
1152
1403
  // the key a consumer reads to tell them apart.
1153
- testSync("both name the type as their source", () =>
1154
- expect((
1155
- keyOf(json, "contactEmail", "x-reventless-semantic-source"),
1156
- keyOf(json, "taxRate", "x-reventless-semantic-source"),
1157
- ))->toEqual((Some(JSON.Encode.string("type")), Some(JSON.Encode.string("type"))))
1404
+ testSync(
1405
+ "both name the type as their source",
1406
+ () =>
1407
+ expect((
1408
+ keyOf(json, "contactEmail", "x-reventless-semantic-source"),
1409
+ keyOf(json, "taxRate", "x-reventless-semantic-source"),
1410
+ ))->toEqual((Some(JSON.Encode.string("type")), Some(JSON.Encode.string("type")))),
1158
1411
  )
1159
1412
  })
1160
1413
 
@@ -1166,61 +1419,75 @@ describe("SuryToJsonSchema:", () => {
1166
1419
  // says a deployment needs an upcaster before it can take the change.
1167
1420
  describe("Money changes the field's shape, and says so:", () => {
1168
1421
  let json = SuryToJsonSchema.deriveObjectSchema(
1169
- S.schema(s =>
1170
- {
1171
- "price": s.matches(Reventless.Money.schema),
1172
- "quantity": s.matches(S.float),
1173
- }
1422
+ S.schema(
1423
+ s =>
1424
+ {
1425
+ "price": s.matches(Reventless.Money.schema),
1426
+ "quantity": s.matches(S.float),
1427
+ },
1174
1428
  )->S.castToUnknown,
1175
1429
  )
1176
1430
  let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
1177
1431
 
1178
- testSync("the field is an object, not a number", () =>
1179
- expect(keyOf("price", "type"))->toEqual(Some(JSON.Encode.string("object")))
1432
+ testSync(
1433
+ "the field is an object, not a number",
1434
+ () => expect(keyOf("price", "type"))->toEqual(Some(JSON.Encode.string("object"))),
1180
1435
  )
1181
1436
 
1182
- testSync("it carries the money id, sourced from the type", () =>
1183
- expect((keyOf("price", "x-reventless-semantic"), keyOf("price", "x-reventless-semantic-source")))
1184
- ->toEqual((Some(JSON.Encode.string("money")), Some(JSON.Encode.string("type"))))
1437
+ testSync(
1438
+ "it carries the money id, sourced from the type",
1439
+ () =>
1440
+ expect((
1441
+ keyOf("price", "x-reventless-semantic"),
1442
+ keyOf("price", "x-reventless-semantic-source"),
1443
+ ))->toEqual((Some(JSON.Encode.string("money")), Some(JSON.Encode.string("type")))),
1185
1444
  )
1186
1445
 
1187
1446
  // The control: an ordinary `float` price is what the field looked like
1188
1447
  // before, and it emits nothing at all. Without this the assertions above
1189
1448
  // would pass just as well against a schema that had always said `money`.
1190
- testSync("a plain numeric field emits no semantic", () =>
1191
- expect((keyOf("quantity", "type"), keyOf("quantity", "x-reventless-semantic")))->toEqual((
1192
- Some(JSON.Encode.string("number")),
1193
- None,
1194
- ))
1449
+ testSync(
1450
+ "a plain numeric field emits no semantic",
1451
+ () =>
1452
+ expect((keyOf("quantity", "type"), keyOf("quantity", "x-reventless-semantic")))->toEqual((
1453
+ Some(JSON.Encode.string("number")),
1454
+ None,
1455
+ )),
1195
1456
  )
1196
1457
 
1197
- testSync("the amount and the currency both reach the wire", () => {
1198
- let inner = getPropertyOf(json, "price")->Option.getOr(JSON.Encode.null)
1199
- expect((
1200
- getPropertyOf(inner, "amount")->Option.flatMap(s => getProperty(s, "type")),
1201
- getPropertyOf(inner, "currency")->Option.flatMap(s => getProperty(s, "type")),
1202
- ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("string"))))
1203
- })
1458
+ testSync(
1459
+ "the amount and the currency both reach the wire",
1460
+ () => {
1461
+ let inner = getPropertyOf(json, "price")->Option.getOr(JSON.Encode.null)
1462
+ expect((
1463
+ getPropertyOf(inner, "amount")->Option.flatMap(s => getProperty(s, "type")),
1464
+ getPropertyOf(inner, "currency")->Option.flatMap(s => getProperty(s, "type")),
1465
+ ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("string"))))
1466
+ },
1467
+ )
1204
1468
 
1205
1469
  // The closed currency arrives as an enum rather than a free string, which is
1206
1470
  // what lets a consumer build a currency picker instead of a text box — and
1207
1471
  // what makes `"eur"` unrepresentable on the wire as well as in the source.
1208
- testSync("the currency is an enum of ISO codes", () => {
1209
- let codes =
1210
- getPropertyOf(json, "price")
1211
- ->Option.getOr(JSON.Encode.null)
1212
- ->getPropertyOf("currency")
1213
- ->Option.flatMap(s => getProperty(s, "enum"))
1214
- ->Option.flatMap(JSON.Decode.array)
1215
- ->Option.getOr([])
1216
- ->Array.filterMap(JSON.Decode.string)
1217
- expect((
1218
- codes->Array.length == Reventless.Currency.all->Array.length,
1219
- codes->Array.includes("EUR"),
1220
- codes->Array.includes("JPY"),
1221
- codes->Array.includes("eur"),
1222
- ))->toEqual((true, true, true, false))
1223
- })
1472
+ testSync(
1473
+ "the currency is an enum of ISO codes",
1474
+ () => {
1475
+ let codes =
1476
+ getPropertyOf(json, "price")
1477
+ ->Option.getOr(JSON.Encode.null)
1478
+ ->getPropertyOf("currency")
1479
+ ->Option.flatMap(s => getProperty(s, "enum"))
1480
+ ->Option.flatMap(JSON.Decode.array)
1481
+ ->Option.getOr([])
1482
+ ->Array.filterMap(JSON.Decode.string)
1483
+ expect((
1484
+ codes->Array.length == Reventless.Currency.all->Array.length,
1485
+ codes->Array.includes("EUR"),
1486
+ codes->Array.includes("JPY"),
1487
+ codes->Array.includes("eur"),
1488
+ ))->toEqual((true, true, true, false))
1489
+ },
1490
+ )
1224
1491
  })
1225
1492
 
1226
1493
  // The second composite, and the one whose emission the reader's shape rung
@@ -1232,46 +1499,55 @@ describe("SuryToJsonSchema:", () => {
1232
1499
  // before the `dateRange` marker reaches it in a release.
1233
1500
  describe("DateRange is an object whose parts stay date-times:", () => {
1234
1501
  let json = SuryToJsonSchema.deriveObjectSchema(
1235
- S.schema(s =>
1236
- {
1237
- "deliveryWindow": s.matches(Reventless.DateRange.schema),
1238
- "orderedAt": s.matches(Reventless.DateTime.schema),
1239
- }
1502
+ S.schema(
1503
+ s =>
1504
+ {
1505
+ "deliveryWindow": s.matches(Reventless.DateRange.schema),
1506
+ "orderedAt": s.matches(Reventless.DateTime.schema),
1507
+ },
1240
1508
  )->S.castToUnknown,
1241
1509
  )
1242
1510
  let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
1243
1511
 
1244
- testSync("the field is an object, not a string", () =>
1245
- expect(keyOf("deliveryWindow", "type"))->toEqual(Some(JSON.Encode.string("object")))
1512
+ testSync(
1513
+ "the field is an object, not a string",
1514
+ () => expect(keyOf("deliveryWindow", "type"))->toEqual(Some(JSON.Encode.string("object"))),
1246
1515
  )
1247
1516
 
1248
- testSync("it carries the dateRange id, sourced from the type", () =>
1249
- expect((
1250
- keyOf("deliveryWindow", "x-reventless-semantic"),
1251
- keyOf("deliveryWindow", "x-reventless-semantic-source"),
1252
- ))->toEqual((Some(JSON.Encode.string("dateRange")), Some(JSON.Encode.string("type"))))
1517
+ testSync(
1518
+ "it carries the dateRange id, sourced from the type",
1519
+ () =>
1520
+ expect((
1521
+ keyOf("deliveryWindow", "x-reventless-semantic"),
1522
+ keyOf("deliveryWindow", "x-reventless-semantic-source"),
1523
+ ))->toEqual((Some(JSON.Encode.string("dateRange")), Some(JSON.Encode.string("type")))),
1253
1524
  )
1254
1525
 
1255
1526
  // The property that is not incidental: each part keeps its own date-time
1256
1527
  // marker under the composite. `dateTime` predates the generic marker and
1257
1528
  // surfaces as `format: "date-time"` (not `x-reventless-semantic`), so that is
1258
1529
  // what a walker reads to see the two instants without knowing about ranges.
1259
- testSync("both instants keep their own date-time marker", () => {
1260
- let inner = getPropertyOf(json, "deliveryWindow")->Option.getOr(JSON.Encode.null)
1261
- expect((
1262
- getPropertyOf(inner, "start")->Option.flatMap(s => getProperty(s, "format")),
1263
- getPropertyOf(inner, "end")->Option.flatMap(s => getProperty(s, "format")),
1264
- ))->toEqual((Some(JSON.Encode.string("date-time")), Some(JSON.Encode.string("date-time"))))
1265
- })
1530
+ testSync(
1531
+ "both instants keep their own date-time marker",
1532
+ () => {
1533
+ let inner = getPropertyOf(json, "deliveryWindow")->Option.getOr(JSON.Encode.null)
1534
+ expect((
1535
+ getPropertyOf(inner, "start")->Option.flatMap(s => getProperty(s, "format")),
1536
+ getPropertyOf(inner, "end")->Option.flatMap(s => getProperty(s, "format")),
1537
+ ))->toEqual((Some(JSON.Encode.string("date-time")), Some(JSON.Encode.string("date-time"))))
1538
+ },
1539
+ )
1266
1540
 
1267
1541
  // The control, mirroring the `Money` block's plain-float case: a lone
1268
1542
  // date-time field stays a string and never becomes an object, so the
1269
1543
  // object-ness above is the composite's doing and not the walk's.
1270
- testSync("a lone date-time field stays a string", () =>
1271
- expect((keyOf("orderedAt", "type"), keyOf("orderedAt", "format")))->toEqual((
1272
- Some(JSON.Encode.string("string")),
1273
- Some(JSON.Encode.string("date-time")),
1274
- ))
1544
+ testSync(
1545
+ "a lone date-time field stays a string",
1546
+ () =>
1547
+ expect((keyOf("orderedAt", "type"), keyOf("orderedAt", "format")))->toEqual((
1548
+ Some(JSON.Encode.string("string")),
1549
+ Some(JSON.Encode.string("date-time")),
1550
+ )),
1275
1551
  )
1276
1552
  })
1277
1553
 
@@ -1282,43 +1558,52 @@ describe("SuryToJsonSchema:", () => {
1282
1558
  // separate a declaration from a lucky guess — the marker and its `type` source.
1283
1559
  describe("GeoPoint is an object carrying the geoPoint marker:", () => {
1284
1560
  let json = SuryToJsonSchema.deriveObjectSchema(
1285
- S.schema(s =>
1286
- {
1287
- "location": s.matches(Reventless.GeoPoint.schema),
1288
- "lat": s.matches(S.float),
1289
- }
1561
+ S.schema(
1562
+ s =>
1563
+ {
1564
+ "location": s.matches(Reventless.GeoPoint.schema),
1565
+ "lat": s.matches(S.float),
1566
+ },
1290
1567
  )->S.castToUnknown,
1291
1568
  )
1292
1569
  let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
1293
1570
 
1294
- testSync("the field is an object, not a pair of numbers", () =>
1295
- expect(keyOf("location", "type"))->toEqual(Some(JSON.Encode.string("object")))
1571
+ testSync(
1572
+ "the field is an object, not a pair of numbers",
1573
+ () => expect(keyOf("location", "type"))->toEqual(Some(JSON.Encode.string("object"))),
1296
1574
  )
1297
1575
 
1298
- testSync("it carries the geoPoint id, sourced from the type", () =>
1299
- expect((
1300
- keyOf("location", "x-reventless-semantic"),
1301
- keyOf("location", "x-reventless-semantic-source"),
1302
- ))->toEqual((Some(JSON.Encode.string("geoPoint")), Some(JSON.Encode.string("type"))))
1576
+ testSync(
1577
+ "it carries the geoPoint id, sourced from the type",
1578
+ () =>
1579
+ expect((
1580
+ keyOf("location", "x-reventless-semantic"),
1581
+ keyOf("location", "x-reventless-semantic-source"),
1582
+ ))->toEqual((Some(JSON.Encode.string("geoPoint")), Some(JSON.Encode.string("type")))),
1303
1583
  )
1304
1584
 
1305
1585
  // The control, and the whole reason the composite exists: a bare `lat` float
1306
1586
  // is exactly what the flattened pair looked like, and it emits nothing at
1307
1587
  // all. Everything a map knew about it, it inferred from the name.
1308
- testSync("a bare lat float emits no semantic", () =>
1309
- expect((keyOf("lat", "type"), keyOf("lat", "x-reventless-semantic")))->toEqual((
1310
- Some(JSON.Encode.string("number")),
1311
- None,
1312
- ))
1313
- )
1314
-
1315
- testSync("both coordinates reach the wire as numbers", () => {
1316
- let inner = getPropertyOf(json, "location")->Option.getOr(JSON.Encode.null)
1317
- expect((
1318
- getPropertyOf(inner, "lat")->Option.flatMap(s => getProperty(s, "type")),
1319
- getPropertyOf(inner, "lng")->Option.flatMap(s => getProperty(s, "type")),
1320
- ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("number"))))
1321
- })
1588
+ testSync(
1589
+ "a bare lat float emits no semantic",
1590
+ () =>
1591
+ expect((keyOf("lat", "type"), keyOf("lat", "x-reventless-semantic")))->toEqual((
1592
+ Some(JSON.Encode.string("number")),
1593
+ None,
1594
+ )),
1595
+ )
1596
+
1597
+ testSync(
1598
+ "both coordinates reach the wire as numbers",
1599
+ () => {
1600
+ let inner = getPropertyOf(json, "location")->Option.getOr(JSON.Encode.null)
1601
+ expect((
1602
+ getPropertyOf(inner, "lat")->Option.flatMap(s => getProperty(s, "type")),
1603
+ getPropertyOf(inner, "lng")->Option.flatMap(s => getProperty(s, "type")),
1604
+ ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("number"))))
1605
+ },
1606
+ )
1322
1607
  })
1323
1608
 
1324
1609
  // A nested record's own optional field. `optional` is computed for the schema
@@ -1326,18 +1611,20 @@ describe("SuryToJsonSchema:", () => {
1326
1611
  // one level in has only its nullable wrapper to say it may be absent — and the
1327
1612
  // wrapper is exactly what a reader looking at `required` never sees.
1328
1613
  describe("an optional field inside a nested record:", () => {
1329
- let inner = S.schema(s =>
1330
- {
1331
- "street": s.matches(S.string),
1332
- "unit": s.matches(S.option(S.string)),
1333
- }
1614
+ let inner = S.schema(
1615
+ s =>
1616
+ {
1617
+ "street": s.matches(S.string),
1618
+ "unit": s.matches(S.option(S.string)),
1619
+ },
1334
1620
  )
1335
1621
  let json = SuryToJsonSchema.deriveObjectSchema(
1336
- S.schema(s =>
1337
- {
1338
- "id": s.matches(S.string),
1339
- "address": s.matches(inner),
1340
- }
1622
+ S.schema(
1623
+ s =>
1624
+ {
1625
+ "id": s.matches(S.string),
1626
+ "address": s.matches(inner),
1627
+ },
1341
1628
  )->S.castToUnknown,
1342
1629
  )
1343
1630
  let nestedRequired =
@@ -1347,13 +1634,83 @@ describe("SuryToJsonSchema:", () => {
1347
1634
  ->Option.getOr([])
1348
1635
  ->Array.filterMap(JSON.Decode.string)
1349
1636
 
1350
- testSync("is not listed as required", () =>
1351
- expect(nestedRequired->Array.includes("unit"))->toBe(false)
1637
+ testSync(
1638
+ "is not listed as required",
1639
+ () => expect(nestedRequired->Array.includes("unit"))->toBe(false),
1352
1640
  )
1353
1641
 
1354
- testSync("while its non-optional sibling still is", () =>
1355
- expect(nestedRequired->Array.includes("street"))->toBe(true)
1642
+ testSync(
1643
+ "while its non-optional sibling still is",
1644
+ () => expect(nestedRequired->Array.includes("street"))->toBe(true),
1356
1645
  )
1357
1646
  })
1358
1647
 
1648
+ // The value a field opens with is JSON Schema's own keyword, not an
1649
+ // `x-reventless-` extension — and it rides on the field's schema, so it
1650
+ // survives the one place a name-keyed annotation spec cannot reach: a record
1651
+ // nested inside a command.
1652
+ describe("a field declaring a default:", () => {
1653
+ let defaultOf = (json, ~field) =>
1654
+ getPropertyOf(json, field)->Option.flatMap(s => getProperty(s, "default"))
1655
+
1656
+ let json = SuryToJsonSchema.deriveObjectSchema(
1657
+ S.schema(
1658
+ s =>
1659
+ {
1660
+ "quantity": s.matches(S.int->Reventless.FieldDefault.int(1)),
1661
+ "shippingMethod": s.matches(S.string->Reventless.FieldDefault.string("Standard")),
1662
+ "plain": s.matches(S.int),
1663
+ },
1664
+ )->S.castToUnknown,
1665
+ )
1666
+
1667
+ testSync(
1668
+ "carries it as `default`",
1669
+ () => expect(defaultOf(json, ~field="quantity"))->toEqual(Some(JSON.Encode.int(1))),
1670
+ )
1671
+
1672
+ testSync(
1673
+ "keeps the shape the value actually has",
1674
+ () =>
1675
+ expect(
1676
+ getPropertyOf(json, "quantity")->Option.flatMap(s => getProperty(s, "type")),
1677
+ )->toEqual(Some(JSON.Encode.string("integer"))),
1678
+ )
1679
+
1680
+ testSync(
1681
+ "carries a string default the same way",
1682
+ () =>
1683
+ expect(defaultOf(json, ~field="shippingMethod"))->toEqual(
1684
+ Some(JSON.Encode.string("Standard")),
1685
+ ),
1686
+ )
1687
+
1688
+ testSync(
1689
+ "while a field declaring none says nothing",
1690
+ () => expect(defaultOf(json, ~field="plain"))->toBe(None),
1691
+ )
1692
+
1693
+ // The case the whole marker exists for: a name-keyed annotation spec speaks
1694
+ // for a record's own fields, and an order's line items are one level in.
1695
+ testSync(
1696
+ "and reaches a field of a nested record",
1697
+ () => {
1698
+ let lineItem = S.schema(
1699
+ s =>
1700
+ {
1701
+ "productId": s.matches(S.string),
1702
+ "quantity": s.matches(S.int->Reventless.FieldDefault.int(1)),
1703
+ },
1704
+ )
1705
+ let nested = SuryToJsonSchema.deriveObjectSchema(
1706
+ S.schema(s => {"lineItems": s.matches(S.array(lineItem))})->S.castToUnknown,
1707
+ )
1708
+ expect(
1709
+ getPropertyOf(nested, "lineItems")
1710
+ ->Option.flatMap(a => getProperty(a, "items"))
1711
+ ->Option.flatMap(i => defaultOf(i, ~field="quantity")),
1712
+ )->toEqual(Some(JSON.Encode.int(1)))
1713
+ },
1714
+ )
1715
+ })
1359
1716
  })