@reventlessdev/reventless-core 3.0.0-alpha.223 → 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.
- package/CHANGELOG.md +22 -0
- package/package.json +7 -7
- package/src/admin/Platform_Admin_Structure.res +10 -0
- package/src/admin/Platform_Admin_Structure.res.mjs +6 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res +11 -1
- package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +13 -1
- package/src/components/Api/Api_Ids.res +63 -0
- package/src/components/Api/Api_Ids.res.mjs +43 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res +121 -28
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +97 -27
- package/src/components/Api/QueryDbListQuery.res +13 -5
- package/src/components/Api/QueryDbListQuery.res.mjs +5 -3
- package/src/components/Api/SchemaType.res +36 -2
- package/src/components/Api/SchemaType.res.mjs +31 -10
- package/src/plugin/component/Plugin_Structure.res +19 -0
- package/src/plugin/component/Plugin_Structure.res.mjs +11 -2
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +63 -0
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +117 -4
- package/tests/admin/Platform_PluginStructuresApiTest.res +10 -0
- package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +23 -2
- package/tests/api/Api_IdsTest.res +143 -0
- package/tests/api/Api_IdsTest.res.mjs +97 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res +169 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +179 -0
- package/tests/plugin/PluginStructureTest.res +90 -0
- package/tests/plugin/PluginStructureTest.res.mjs +86 -8
- package/tests/plugin/StateViewSlice/PsCategoriesView.res +17 -0
- package/tests/plugin/StateViewSlice/PsCategoriesView.res.mjs +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
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
|
+
|
|
6
28
|
# 3.0.0-alpha.223 (2026-08-10)
|
|
7
29
|
|
|
8
30
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
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.
|
|
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.
|
|
36
|
-
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.3",
|
|
35
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.4",
|
|
37
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
38
|
-
"@reventlessdev/rescript-
|
|
37
|
+
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.4",
|
|
39
38
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
40
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
41
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
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,7 +25,11 @@ 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
|
-
`
|
|
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}`,
|
|
@@ -111,6 +115,12 @@ let encodeQueryableDef = (r: queryableDef): JSON.t =>
|
|
|
111
115
|
// survives the filter anyway.
|
|
112
116
|
("visibility", r.visibility->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
113
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)),
|
|
114
124
|
])->JSON.Encode.object
|
|
115
125
|
|
|
116
126
|
let encodeEventDef = (e: eventDef): JSON.t =>
|
|
@@ -10,7 +10,7 @@ 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}`,
|
|
@@ -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
|
}
|
|
@@ -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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
6
|
+
import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
|
|
6
7
|
import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
|
|
7
8
|
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
8
9
|
import * as GraphQL_Stitcher$ReventlessCore from "./GraphQL_Stitcher.res.mjs";
|
|
@@ -72,7 +73,20 @@ function fromSchemaType(_required, _asInputOpt, _st, collectedTypes, seenTypes)
|
|
|
72
73
|
}
|
|
73
74
|
return name$1 + bang;
|
|
74
75
|
case "Semantic" :
|
|
75
|
-
|
|
76
|
+
let inner = st._1;
|
|
77
|
+
if (typeof inner === "object" && inner.TAG === "ObjectRef") {
|
|
78
|
+
let name$2 = inner._0;
|
|
79
|
+
if (Stdlib_Option.isSome(SchemaType$ReventlessCore.canonicalName(st._0.id))) {
|
|
80
|
+
let typeName = asInput ? name$2 + "Input" : name$2;
|
|
81
|
+
if (!seenTypes.has(typeName)) {
|
|
82
|
+
seenTypes.add(typeName);
|
|
83
|
+
let typeDef$1 = objectRefToGraphQL(asInput, typeName, inner._1, collectedTypes, seenTypes);
|
|
84
|
+
collectedTypes.push(typeDef$1);
|
|
85
|
+
}
|
|
86
|
+
return typeName + bang;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
_st = inner;
|
|
76
90
|
_asInputOpt = asInput;
|
|
77
91
|
continue;
|
|
78
92
|
}
|
|
@@ -172,11 +186,55 @@ function scalarOfSchemaType(_st) {
|
|
|
172
186
|
};
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
function isKeyFieldName(name) {
|
|
190
|
+
if (name.length > 2) {
|
|
191
|
+
return name.endsWith("Id");
|
|
192
|
+
} else {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveKeyField(entityName, schema) {
|
|
198
|
+
let match = StateAnnotations$Reventless.getSpec(schema);
|
|
199
|
+
let declared = match !== undefined ? match.ids[0] : undefined;
|
|
200
|
+
if (declared !== undefined) {
|
|
201
|
+
return [
|
|
202
|
+
declared,
|
|
203
|
+
"annotation"
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
let candidates = Object.keys(Stdlib_Option.getOr(SchemaType$ReventlessCore.fromSuryObject("", schema), {})).filter(isKeyFieldName);
|
|
207
|
+
let singular = Api_Naming$ReventlessCore.singularize(Api_Naming$ReventlessCore.stripViewSuffix(entityName));
|
|
208
|
+
let conventional = singular.slice(0, 1).toLowerCase() + singular.slice(1, singular.length) + "Id";
|
|
209
|
+
if (candidates.includes(conventional)) {
|
|
210
|
+
return [
|
|
211
|
+
conventional,
|
|
212
|
+
"convention"
|
|
213
|
+
];
|
|
214
|
+
} else if (candidates.length === 1) {
|
|
215
|
+
return [
|
|
216
|
+
candidates[0],
|
|
217
|
+
"sole"
|
|
218
|
+
];
|
|
219
|
+
} else {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function entityNameOf(entry) {
|
|
225
|
+
let n = entry.specName;
|
|
226
|
+
if (n !== undefined) {
|
|
227
|
+
return n;
|
|
228
|
+
}
|
|
229
|
+
let i = entry.returnTypeName.lastIndexOf("_");
|
|
230
|
+
if (i !== -1) {
|
|
231
|
+
return entry.returnTypeName.slice(i + 1 | 0, entry.returnTypeName.length);
|
|
232
|
+
} else {
|
|
233
|
+
return entry.returnTypeName;
|
|
179
234
|
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function deriveServerCapability(entityName, schema) {
|
|
180
238
|
let fieldTypes = Stdlib_Option.getOr(SchemaType$ReventlessCore.fromSuryObject("", schema), {});
|
|
181
239
|
let scalarOf = fieldName => Stdlib_Option.mapOr(fieldTypes[fieldName], "String", scalarOfSchemaType);
|
|
182
240
|
let filterFields = [];
|
|
@@ -201,26 +259,35 @@ function deriveServerCapability(schema) {
|
|
|
201
259
|
return;
|
|
202
260
|
}
|
|
203
261
|
};
|
|
204
|
-
spec.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
pushFilter(name,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
262
|
+
let spec = StateAnnotations$Reventless.getSpec(schema);
|
|
263
|
+
if (spec !== undefined) {
|
|
264
|
+
spec.ids.forEach(name => {
|
|
265
|
+
pushFilter(name, false);
|
|
266
|
+
pushSort(name);
|
|
267
|
+
});
|
|
268
|
+
spec.compositeIds.forEach(name => pushFilter(name, false));
|
|
269
|
+
spec.subIds.forEach(name => {
|
|
270
|
+
pushFilter(name, true);
|
|
271
|
+
pushSort(name);
|
|
272
|
+
});
|
|
273
|
+
spec.compositeSubIds.forEach(name => {
|
|
274
|
+
pushFilter(name, true);
|
|
275
|
+
pushSort(name);
|
|
276
|
+
});
|
|
277
|
+
spec.indexes.forEach(param => {
|
|
278
|
+
let name = param[0];
|
|
279
|
+
pushFilter(name, false);
|
|
280
|
+
pushSort(name);
|
|
281
|
+
});
|
|
282
|
+
spec.scan.forEach(name => pushFilter(name, false));
|
|
283
|
+
spec.scanSort.forEach(pushSort);
|
|
284
|
+
}
|
|
285
|
+
let match = resolveKeyField(entityName, schema);
|
|
286
|
+
if (match !== undefined) {
|
|
287
|
+
let field = match[0];
|
|
288
|
+
pushFilter(field, false);
|
|
289
|
+
pushSort(field);
|
|
290
|
+
}
|
|
224
291
|
return {
|
|
225
292
|
filterFields: filterFields,
|
|
226
293
|
sortFields: sortFields
|
|
@@ -435,7 +502,7 @@ function generate(mutationEntries, queryEntries) {
|
|
|
435
502
|
types.push(t);
|
|
436
503
|
});
|
|
437
504
|
}
|
|
438
|
-
let capability = deriveServerCapability(entry.stateSchema);
|
|
505
|
+
let capability = deriveServerCapability(entityNameOf(entry), entry.stateSchema);
|
|
439
506
|
let connectionFilterTypeName = entry.returnTypeName + "Filter";
|
|
440
507
|
if (!seenTypes.has(connectionFilterTypeName)) {
|
|
441
508
|
seenTypes.add(connectionFilterTypeName);
|
|
@@ -491,6 +558,9 @@ export {
|
|
|
491
558
|
deriveSubIdFilterType,
|
|
492
559
|
emptyCapability,
|
|
493
560
|
scalarOfSchemaType,
|
|
561
|
+
isKeyFieldName,
|
|
562
|
+
resolveKeyField,
|
|
563
|
+
entityNameOf,
|
|
494
564
|
deriveServerCapability,
|
|
495
565
|
validateScanSortAlignment,
|
|
496
566
|
deriveConnectionFilterType,
|
|
@@ -503,4 +573,4 @@ export {
|
|
|
503
573
|
deriveMutationFieldFromObject,
|
|
504
574
|
generate,
|
|
505
575
|
}
|
|
506
|
-
/*
|
|
576
|
+
/* Api_Naming-ReventlessCore Not a pure module */
|
|
@@ -71,15 +71,16 @@ let buildConnection = (
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Compute the Relay connection response (edges + pageInfo) for `items` under the
|
|
74
|
-
// GraphQL args. `decodeLocalId` maps a possibly-Relay-encoded id to its local id
|
|
75
|
-
//
|
|
76
|
-
//
|
|
74
|
+
// GraphQL args. `decodeLocalId` maps a possibly-Relay-encoded id to its local id;
|
|
75
|
+
// it defaults to the shared rule so every caller applies the same one — the AWS
|
|
76
|
+
// Lambda used to pass a no-op here, which silently made `filter.ids` reject a
|
|
77
|
+
// global id on that provider only. Everything else is pure over the item array.
|
|
77
78
|
let run = (
|
|
78
79
|
~items: array<JSON.t>,
|
|
79
80
|
~argsDict: Dict.t<JSON.t>,
|
|
80
81
|
~capability: GraphQL_FragmentGenerator.serverCapability,
|
|
81
82
|
~labelField: string,
|
|
82
|
-
~decodeLocalId: string => option<string
|
|
83
|
+
~decodeLocalId: string => option<string>=Api_Ids.toLocalId,
|
|
83
84
|
): JSON.t => {
|
|
84
85
|
let filterDict =
|
|
85
86
|
argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
@@ -145,11 +146,18 @@ let run = (
|
|
|
145
146
|
getLabel(item)->String.toLowerCase->String.startsWith(p->String.toLowerCase)
|
|
146
147
|
| _ => true
|
|
147
148
|
}
|
|
149
|
+
// Either id form matches, in both directions: the argument may be a Relay
|
|
150
|
+
// global id while the row carries its storage key (the normal case — every
|
|
151
|
+
// door but `node` reports the storage key), and the row may carry a global id
|
|
152
|
+
// where some caller stamped one. Decoding only the row's id, as this did,
|
|
153
|
+
// covered just the second.
|
|
148
154
|
let passIds = switch ids {
|
|
149
155
|
| Some(idList) if idList->Array.length > 0 =>
|
|
150
156
|
let itemId = getId(item)
|
|
151
157
|
let itemLocalId = decodeLocalId(itemId)
|
|
152
|
-
idList->Array.some(i =>
|
|
158
|
+
idList->Array.some(i =>
|
|
159
|
+
i == itemId || itemLocalId == Some(i) || decodeLocalId(i) == Some(itemId)
|
|
160
|
+
)
|
|
153
161
|
| _ => true
|
|
154
162
|
}
|
|
155
163
|
let passPerField = perFieldChecks->Array.every(check => check(item))
|