@reventlessdev/reventless-core 3.0.0-alpha.222 → 3.0.0-alpha.224

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 (30) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/package.json +7 -7
  3. package/src/admin/Platform_Admin_Structure.res +10 -0
  4. package/src/admin/Platform_Admin_Structure.res.mjs +6 -1
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +33 -2
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +18 -2
  7. package/src/components/Api/Api_Ids.res +63 -0
  8. package/src/components/Api/Api_Ids.res.mjs +43 -0
  9. package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
  10. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
  11. package/src/components/Api/QueryDbListQuery.res +13 -5
  12. package/src/components/Api/QueryDbListQuery.res.mjs +5 -3
  13. package/src/components/Api/SchemaType.res +36 -2
  14. package/src/components/Api/SchemaType.res.mjs +31 -10
  15. package/src/plugin/component/Plugin_Structure.res +41 -24
  16. package/src/plugin/component/Plugin_Structure.res.mjs +25 -18
  17. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +118 -7
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +168 -7
  19. package/tests/admin/Platform_PluginStructuresApiTest.res +10 -0
  20. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +23 -2
  21. package/tests/api/Api_IdsTest.res +143 -0
  22. package/tests/api/Api_IdsTest.res.mjs +97 -0
  23. package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
  24. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
  25. package/tests/plugin/PluginStructureTest.res +159 -0
  26. package/tests/plugin/PluginStructureTest.res.mjs +160 -6
  27. package/tests/plugin/StateChangeSlice/PsReserveStock.res +44 -0
  28. package/tests/plugin/StateChangeSlice/PsReserveStock.res.mjs +67 -0
  29. package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
  30. package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.224 (2026-08-11)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **api:** one id contract across the read-side query doors ([c52fac2](https://github.com/ReventlessDev/reventless-core/commit/c52fac2bee87b3dd1fa33b4970a74f3cd3866280))
11
+ * feat(core)!: name semantic composites once instead of once per field ([09536ba](https://github.com/ReventlessDev/reventless-core/commit/09536badf338f898b24d749270acd5c7df851ddb))
12
+ ### Features
13
+
14
+ * **api:** infer a queryable's key field and publish its provenance ([c835a42](https://github.com/ReventlessDev/reventless-core/commit/c835a42a0da07cdc4a3f010212e1f340a4a0ca27))
15
+ * **plugin:** publish singleQueryField on queryableDef ([a724ab5](https://github.com/ReventlessDev/reventless-core/commit/a724ab573614792c0615d68b6486b94da14f9f82))
16
+
17
+ ### BREAKING CHANGES
18
+
19
+ * generated GraphQL type names change. A price field is now
20
+ `MoneyInput!` in a command and `Money!` in a read model, where it was named
21
+ after the field that carried it (`Catalog_AddProductPriceCurrency`,
22
+ `Ordering_SyncNewProductPriceCurrency`, …). AutoUI builds its queries at
23
+ runtime from the component-definitions manifest and adapts on its own; a client
24
+ naming the old types in a hand-written query does not.
25
+
26
+
27
+
28
+ # 3.0.0-alpha.223 (2026-08-10)
29
+
30
+ ### Bug Fixes
31
+
32
+ * **core:** collect [@ref](https://github.com/ref) declared on an array field ([6f9e2fe](https://github.com/ReventlessDev/reventless-core/commit/6f9e2fef2568ad57a8bf2efbaa9cd4830a947f26))
33
+ ### Features
34
+
35
+ * **admin:** carry Internal queryables on their own manifest field ([f5aa03d](https://github.com/ReventlessDev/reventless-core/commit/f5aa03d60b0b2554d116fe9b3784d30f2c4ef356))
36
+
37
+
6
38
  # 3.0.0-alpha.222 (2026-08-10)
7
39
 
8
40
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.222",
3
+ "version": "3.0.0-alpha.224",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.3",
32
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.4",
33
33
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
- "@reventlessdev/rescript-node": "2.0.0-alpha.3",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
37
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.132",
39
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.3",
37
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.4",
40
38
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.106"
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.134",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.108",
41
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -121,6 +121,16 @@ let pluginReadModel: queryableDef = {
121
121
  statusField: Some("status"),
122
122
  visibility: None,
123
123
  chapter: None,
124
+ // The admin fragment hand-declares its query names rather than deriving them from
125
+ // the read-model name, so the singular is taken from the same call
126
+ // `PluginBaseFragment.queryNames.singleFieldName` makes — not singularised here.
127
+ singleQueryField: Some(Api_Naming.adminField(~name="Plugin")),
128
+ // No key field to name: the row id is `name@version` (`Plugin.makeId`), and the
129
+ // state carries the two halves separately rather than the composed key. `None`
130
+ // is the honest answer — the same one the resolver ladder reaches for a state
131
+ // with no `*Id` field.
132
+ idField: None,
133
+ idFieldSource: None,
124
134
  }
125
135
 
126
136
  let structure: pluginStructure = {
@@ -135,6 +135,8 @@ let pluginReadModel_labelFieldSource = "convention";
135
135
 
136
136
  let pluginReadModel_statusField = "status";
137
137
 
138
+ let pluginReadModel_singleQueryField = Api_Naming$ReventlessCore.adminField("Plugin");
139
+
138
140
  let pluginReadModel = {
139
141
  name: "Plugins",
140
142
  queryField: pluginReadModel_queryField,
@@ -146,7 +148,10 @@ let pluginReadModel = {
146
148
  labelFieldSource: pluginReadModel_labelFieldSource,
147
149
  statusField: pluginReadModel_statusField,
148
150
  visibility: undefined,
149
- chapter: undefined
151
+ chapter: undefined,
152
+ singleQueryField: pluginReadModel_singleQueryField,
153
+ idField: undefined,
154
+ idFieldSource: undefined
150
155
  };
151
156
 
152
157
  let structure_readModels = [pluginReadModel];
@@ -25,12 +25,23 @@ let sdlTypes: array<string> = [
25
25
  // with, and the two lists must stay independently evolvable.
26
26
  `type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
27
27
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
28
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n}`,
28
+ // `singleQueryField` / `idField` / `idFieldSource` are nullable, not `String!`:
29
+ // structures persisted before the fields existed decode as `None`, a hand-rolled
30
+ // def may decline to state them, and a state whose key cannot be resolved has no
31
+ // `idField` to report.
32
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n}`,
29
33
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
30
34
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
31
35
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
32
36
  `type Platform_ExtensionDef {\n name: String!\n delegateNames: [String!]!\n eventTypes: [String!]!\n commandTypes: [String!]!\n}`,
33
- `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`,
37
+ // `internalQueryables` is the complement of the `readModels` / `stateViewSlices`
38
+ // filter, not an addition to either: it carries exactly the components the
39
+ // filter removed. A consumer enumerating surfaces reads the two filtered lists
40
+ // and is structurally unable to turn an Internal view into a page; a consumer
41
+ // resolving a reference target reads all three. Keeping it a separate field is
42
+ // what makes that separation structural rather than a filter every enumeration
43
+ // site has to remember.
44
+ `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n internalQueryables: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`,
34
45
  ]
35
46
 
36
47
  let sdlQueryField: string = ` Platform_ComponentDefinitions: [Platform_ComponentDefinitionEntry!]!`
@@ -75,6 +86,13 @@ let encodeCommandDef = (c: commandDef): JSON.t =>
75
86
  // Internal ReadModels / StateViewSlices are carried in pluginStructure for developer
76
87
  // tooling but must stay out of the deployed AutoUI — the menu, drill-down pages and
77
88
  // queryable defs are all derived from this response, so filter them here.
89
+ //
90
+ // Hidden from the menu is not the same question as denied as a reference target: a
91
+ // denormalised lookup view is precisely the thing an author wants referenceable but
92
+ // not navigable. The filtered lists answer the first question, `internalQueryables`
93
+ // (below) answers the second. Neither is a security boundary — an Internal view
94
+ // keeps its resolver and stays queryable, so naming it here publishes a fact the
95
+ // caller is already entitled to.
78
96
  let isPublicQueryable = (q: queryableDef): bool => q.visibility != Some("Internal")
79
97
 
80
98
  let encodeQueryableDef = (r: queryableDef): JSON.t =>
@@ -97,6 +115,12 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
97
115
  // survives the filter anyway.
98
116
  ("visibility", r.visibility->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
99
117
  ("chapter", r.chapter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
118
+ (
119
+ "singleQueryField",
120
+ r.singleQueryField->Option.mapOr(JSON.Encode.null, JSON.Encode.string),
121
+ ),
122
+ ("idField", r.idField->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
123
+ ("idFieldSource", r.idFieldSource->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
100
124
  ])->JSON.Encode.object
101
125
 
102
126
  let encodeEventDef = (e: eventDef): JSON.t =>
@@ -190,6 +214,13 @@ let encodePluginStructureEntry = (~pluginId: string, def: pluginStructure): JSON
190
214
  ->Array.map(encodeQueryableDef)
191
215
  ->JSON.Encode.array,
192
216
  ),
217
+ (
218
+ "internalQueryables",
219
+ Array.concat(def.readModels, def.stateViewSlices)
220
+ ->Array.filter(q => !isPublicQueryable(q))
221
+ ->Array.map(encodeQueryableDef)
222
+ ->JSON.Encode.array,
223
+ ),
193
224
  ("stateChangeSlices", def.stateChangeSlices->Array.map(encodeWritableDef)->JSON.Encode.array),
194
225
  ("aggregates", def.aggregates->Array.map(encodeWritableDef)->JSON.Encode.array),
195
226
  (
@@ -10,12 +10,12 @@ let sdlTypes = [
10
10
  `type Platform_EventDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
11
11
  `type Platform_ErrorDef {\n name: String!\n schema: String!\n references: [Platform_FieldReference!]!\n}`,
12
12
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
13
- `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n}`,
13
+ `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n statusField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n}`,
14
14
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
15
15
  `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
16
16
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
17
17
  `type Platform_ExtensionDef {\n name: String!\n delegateNames: [String!]!\n eventTypes: [String!]!\n commandTypes: [String!]!\n}`,
18
- `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`
18
+ `type Platform_ComponentDefinitionEntry {\n pluginId: String!\n readModels: [Platform_ReadSideDef!]!\n stateViewSlices: [Platform_ReadSideDef!]!\n internalQueryables: [Platform_ReadSideDef!]!\n stateChangeSlices: [Platform_WriteSideDef!]!\n aggregates: [Platform_WriteSideDef!]!\n automationSlices: [Platform_AutomationSliceDef!]!\n outboundTranslationSlices: [Platform_OutboundTranslationSliceDef!]!\n inboundTranslationSlices: [Platform_InboundTranslationSliceDef!]!\n extensions: [Platform_ExtensionDef!]!\n}`
19
19
  ];
20
20
 
21
21
  function encodeStrings(ss) {
@@ -132,6 +132,18 @@ function encodeQueryableDef(r) {
132
132
  [
133
133
  "chapter",
134
134
  Stdlib_Option.mapOr(r.chapter, null, prim => prim)
135
+ ],
136
+ [
137
+ "singleQueryField",
138
+ Stdlib_Option.mapOr(r.singleQueryField, null, prim => prim)
139
+ ],
140
+ [
141
+ "idField",
142
+ Stdlib_Option.mapOr(r.idField, null, prim => prim)
143
+ ],
144
+ [
145
+ "idFieldSource",
146
+ Stdlib_Option.mapOr(r.idFieldSource, null, prim => prim)
135
147
  ]
136
148
  ]);
137
149
  }
@@ -325,6 +337,10 @@ function encodePluginStructureEntry(pluginId, def) {
325
337
  "stateViewSlices",
326
338
  def.stateViewSlices.filter(isPublicQueryable).map(encodeQueryableDef)
327
339
  ],
340
+ [
341
+ "internalQueryables",
342
+ def.readModels.concat(def.stateViewSlices).filter(q => !Primitive_object.notequal(q.visibility, "Internal")).map(encodeQueryableDef)
343
+ ],
328
344
  [
329
345
  "stateChangeSlices",
330
346
  def.stateChangeSlices.map(encodeWritableDef)
@@ -0,0 +1,63 @@
1
+ /**
2
+ The one place that knows how a row's GraphQL `id` relates to its storage key.
3
+
4
+ A read-side row is reachable through several id-accepting doors — the typed
5
+ `X(id: ID!)` query, `XsByIds(ids:)`, the list `filter.ids`, and Relay's
6
+ `node(id:)`. They used to disagree about which form they took, so the obvious
7
+ client call `X(id: row.id)` silently returned `null`: the door took the storage
8
+ key while the row advertised the Relay global id. The rule below is what
9
+ `QueryDbListQuery.passIds` already applied to `filter.ids` — lifted out so every
10
+ door applies it, and so provider adapters share one definition rather than three.
11
+
12
+ Provider-neutral on purpose: the contract belongs to neither the local platform
13
+ nor AWS.
14
+ */
15
+
16
+ @val external btoa: string => string = "btoa"
17
+ @val external atob: string => string = "atob"
18
+
19
+ /** `btoa("<TypeName>:<localId>")` — the Relay global id for a row. */
20
+ let encode = (~typeName: string, ~localId: string): string => btoa(`${typeName}:${localId}`)
21
+
22
+ /**
23
+ The `(typeName, localId)` pair inside a global id, or `None` when the string is
24
+ not one. `atob` throws on non-base64 input; a decoded value with no `:` (or a
25
+ leading one, which would mean an empty type name) is not a global id either.
26
+ */
27
+ let decode = (globalId: string): option<(string, string)> =>
28
+ try {
29
+ let decoded = atob(globalId)
30
+ let idx = decoded->String.indexOf(":")
31
+ if idx > 0 {
32
+ Some((
33
+ decoded->String.slice(~start=0, ~end=idx),
34
+ decoded->String.slice(~start=idx + 1, ~end=decoded->String.length),
35
+ ))
36
+ } else {
37
+ None
38
+ }
39
+ } catch {
40
+ | _ => None
41
+ }
42
+
43
+ /**
44
+ The storage key a global id wraps, or `None` if the string is not one.
45
+
46
+ Deliberately NOT a `string => string` that "normalises" an id: a raw key that
47
+ happens to be valid base64 would be silently rewritten into something that
48
+ matches nothing. Callers look the raw id up first and fall back to this — see
49
+ `alternateKey`.
50
+ */
51
+ let toLocalId = (id: string): option<string> => decode(id)->Option.map(((_, localId)) => localId)
52
+
53
+ /**
54
+ The other key worth trying when a lookup by `id` found nothing: the storage key
55
+ inside it, if it was a global id. Ordering matters — the raw id is always tried
56
+ first, so a key that merely looks like base64 keeps resolving to its own row and
57
+ this only ever runs on a miss.
58
+ */
59
+ let alternateKey = (id: string): option<string> =>
60
+ switch toLocalId(id) {
61
+ | Some(localId) if localId != id => Some(localId)
62
+ | _ => None
63
+ }
@@ -0,0 +1,43 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+
5
+ function encode(typeName, localId) {
6
+ return btoa(typeName + `:` + localId);
7
+ }
8
+
9
+ function decode(globalId) {
10
+ try {
11
+ let decoded = atob(globalId);
12
+ let idx = decoded.indexOf(":");
13
+ if (idx > 0) {
14
+ return [
15
+ decoded.slice(0, idx),
16
+ decoded.slice(idx + 1 | 0, decoded.length)
17
+ ];
18
+ } else {
19
+ return;
20
+ }
21
+ } catch (exn) {
22
+ return;
23
+ }
24
+ }
25
+
26
+ function toLocalId(id) {
27
+ return Stdlib_Option.map(decode(id), param => param[1]);
28
+ }
29
+
30
+ function alternateKey(id) {
31
+ let localId = toLocalId(id);
32
+ if (localId !== undefined && localId !== id) {
33
+ return localId;
34
+ }
35
+ }
36
+
37
+ export {
38
+ encode,
39
+ decode,
40
+ toLocalId,
41
+ alternateKey,
42
+ }
43
+ /* No side effect */
@@ -62,10 +62,28 @@ let rec fromSchemaType = (
62
62
  collectedTypes->Array.push(`enum ${name} {\n ${valuesStr}\n}`)
63
63
  }
64
64
  `${name}${bang}`
65
- // GraphQL carries the value's shape, not its meaning: a storage ref is a
66
- // String on the wire exactly as it is in the event log. The semantic reaches
67
- // the UI through the field's JSON Schema, which is the channel that can
68
- // express it.
65
+ // A semantic composite is one named type rather than one per field that uses
66
+ // it. The name comes from the semantic rather than the field path, so every
67
+ // plugin emits a byte-identical definition — which is what lets AppSync union
68
+ // the copies from each source API back into one, the way it already does for
69
+ // the `Node` / `PageInfo` / `SortOrder` base types.
70
+ //
71
+ // GraphQL forbids one name serving as both an object and an input, so the
72
+ // input position takes an `Input` suffix. Only these types are suffixed:
73
+ // positionally-named objects can't collide, since their names already carry
74
+ // the command or read model they belong to.
75
+ | Semantic({id}, ObjectRef(name, fields)) if SchemaType.canonicalName(id)->Option.isSome =>
76
+ let typeName = asInput ? name ++ "Input" : name
77
+ if !(seenTypes->Set.has(typeName)) {
78
+ seenTypes->Set.add(typeName)
79
+ let typeDef = objectRefToGraphQL(~asInput, typeName, fields, collectedTypes, seenTypes)
80
+ collectedTypes->Array.push(typeDef)
81
+ }
82
+ `${typeName}${bang}`
83
+ // Every other semantic carries the value's shape, not its meaning: a storage
84
+ // ref is a String on the wire exactly as it is in the event log. The semantic
85
+ // reaches the UI through the field's JSON Schema, which is the channel that
86
+ // can express it.
69
87
  | Semantic(_, inner) => fromSchemaType(~required, ~asInput, inner, collectedTypes, seenTypes)
70
88
  | Unknown => `String${bang}`
71
89
  }
@@ -187,30 +205,93 @@ let rec scalarOfSchemaType = (st: SchemaType.schemaType): string =>
187
205
  | _ => "String"
188
206
  }
189
207
 
190
- let deriveServerCapability = (schema: S.t<unknown>): serverCapability => {
208
+ // A `*Id`-suffixed field name, case-sensitively. Deliberately NOT
209
+ // `SchemaType.isIdFieldName`, which lowercases before testing the suffix and so
210
+ // accepts `paid` and `valid` — harmless where it is used, but here it would
211
+ // nominate an ordinary word as a row's key.
212
+ let isKeyFieldName = (name: string): bool =>
213
+ name->String.length > 2 && name->String.endsWith("Id")
214
+
215
+ /**
216
+ The field that identifies a row, and which rung answered:
217
+
218
+ - `"annotation"` — the state declares `@id`. Nothing outranks it.
219
+ - `"convention"` — a field named `<singular entity name>Id` exists
220
+ (`Products` → `productId`). A guess, but one that can only fire on a field
221
+ that is actually there.
222
+ - `"sole"` — the state has exactly one `*Id` field, so there is nothing else it
223
+ could be (`AvailableProducts` → `productId`).
224
+
225
+ `None` is the honest answer for a state with several `*Id` fields and no name
226
+ match (`ProductDemand`: `productId` + `categoryId`), or with none at all — those
227
+ need `@id`. Convention outranks sole so a view carrying one foreign key and no
228
+ key of its own is not keyed by the foreign key.
229
+ */
230
+ let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(string, string)> => {
231
+ let declared = switch Reventless.StateAnnotations.getSpec(schema) {
232
+ | Some({ids}) => ids->Array.get(0)
233
+ | None => None
234
+ }
235
+ switch declared {
236
+ | Some(field) => Some((field, "annotation"))
237
+ | None =>
238
+ let candidates =
239
+ SchemaType.fromSuryObject(~typeName="", schema)
240
+ ->Option.getOr(Dict.make())
241
+ ->Dict.keysToArray
242
+ ->Array.filter(isKeyFieldName)
243
+ let singular = entityName->Api_Naming.stripViewSuffix->Api_Naming.singularize
244
+ let conventional =
245
+ singular->String.slice(~start=0, ~end=1)->String.toLowerCase ++
246
+ singular->String.slice(~start=1, ~end=singular->String.length) ++ "Id"
247
+ if candidates->Array.includes(conventional) {
248
+ Some((conventional, "convention"))
249
+ } else if candidates->Array.length == 1 {
250
+ Some((candidates->Array.getUnsafe(0), "sole"))
251
+ } else {
252
+ None
253
+ }
254
+ }
255
+ }
256
+
257
+ // The component name the key-field convention is read against. `specName` is the
258
+ // read model's own `Spec.name`; without it, `returnTypeName` minus its plugin
259
+ // prefix is the same string (`Catalog_Product` → `Product`).
260
+ let entityNameOf = (entry: ReventlessInfra.Api.querySchemaEntry): string =>
261
+ switch entry.specName {
262
+ | Some(n) => n
263
+ | None =>
264
+ switch entry.returnTypeName->String.lastIndexOf("_") {
265
+ | -1 => entry.returnTypeName
266
+ | i =>
267
+ entry.returnTypeName->String.slice(~start=i + 1, ~end=entry.returnTypeName->String.length)
268
+ }
269
+ }
270
+
271
+ let deriveServerCapability = (~entityName: string, schema: S.t<unknown>): serverCapability => {
272
+ let fieldTypes = SchemaType.fromSuryObject(~typeName="", schema)->Option.getOr(Dict.make())
273
+ let scalarOf = (fieldName: string): string =>
274
+ fieldTypes->Dict.get(fieldName)->Option.mapOr("String", scalarOfSchemaType)
275
+
276
+ let filterFields: array<filterField> = []
277
+ let sortFields: array<string> = []
278
+ let seenFilter: Set.t<string> = Set.make()
279
+ let seenSort: Set.t<string> = Set.make()
280
+
281
+ let pushFilter = (name, ~range) =>
282
+ if !(seenFilter->Set.has(name)) {
283
+ seenFilter->Set.add(name)
284
+ filterFields->Array.push({name, gqlType: scalarOf(name), range})
285
+ }
286
+ let pushSort = name =>
287
+ if !(seenSort->Set.has(name)) {
288
+ seenSort->Set.add(name)
289
+ sortFields->Array.push(name)
290
+ }
291
+
191
292
  switch Reventless.StateAnnotations.getSpec(schema) {
192
- | None => emptyCapability
293
+ | None => ()
193
294
  | Some(spec) =>
194
- let fieldTypes = SchemaType.fromSuryObject(~typeName="", schema)->Option.getOr(Dict.make())
195
- let scalarOf = (fieldName: string): string =>
196
- fieldTypes->Dict.get(fieldName)->Option.mapOr("String", scalarOfSchemaType)
197
-
198
- let filterFields: array<filterField> = []
199
- let sortFields: array<string> = []
200
- let seenFilter: Set.t<string> = Set.make()
201
- let seenSort: Set.t<string> = Set.make()
202
-
203
- let pushFilter = (name, ~range) =>
204
- if !(seenFilter->Set.has(name)) {
205
- seenFilter->Set.add(name)
206
- filterFields->Array.push({name, gqlType: scalarOf(name), range})
207
- }
208
- let pushSort = name =>
209
- if !(seenSort->Set.has(name)) {
210
- seenSort->Set.add(name)
211
- sortFields->Array.push(name)
212
- }
213
-
214
295
  spec.ids->Array.forEach(name => {
215
296
  pushFilter(name, ~range=false)
216
297
  pushSort(name)
@@ -234,9 +315,21 @@ let deriveServerCapability = (schema: S.t<unknown>): serverCapability => {
234
315
  // path is needed here.
235
316
  spec.scan->Array.forEach(name => pushFilter(name, ~range=false))
236
317
  spec.scanSort->Array.forEach(name => pushSort(name))
318
+ }
237
319
 
238
- {filterFields, sortFields}
320
+ // A state that declares nothing structural used to land here with an empty
321
+ // capability — no per-field filter and no order-by at all, so every narrowing
322
+ // a client asked for happened client-side over one page. Its key is knowable
323
+ // without the annotation in the common cases; take it. Pushed last, and both
324
+ // pushes dedupe, so a declared `@id` keeps its position and this is a no-op.
325
+ switch resolveKeyField(~entityName, schema) {
326
+ | Some((field, _rung)) =>
327
+ pushFilter(field, ~range=false)
328
+ pushSort(field)
329
+ | None => ()
239
330
  }
331
+
332
+ {filterFields, sortFields}
240
333
  }
241
334
 
242
335
  // Returns one warning per `@scanSort` field that is NOT also a sort key of the
@@ -564,7 +657,7 @@ let generate = (
564
657
  deriveConnectionTypes(~singularTypeName=entry.returnTypeName)
565
658
  ->Array.forEach(t => types->Array.push(t))
566
659
  }
567
- let capability = deriveServerCapability(entry.stateSchema)
660
+ let capability = deriveServerCapability(~entityName=entityNameOf(entry), entry.stateSchema)
568
661
  let connectionFilterTypeName = entry.returnTypeName ++ "Filter"
569
662
  if !(seenTypes->Set.has(connectionFilterTypeName)) {
570
663
  seenTypes->Set.add(connectionFilterTypeName)