@reventlessdev/reventless-core 3.0.0-alpha.241 → 3.0.0-alpha.243
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 +14 -0
- package/package.json +5 -5
- package/src/components/Api/GraphQL_FragmentGenerator.res +49 -1
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +23 -1
- package/src/components/Api/SchemaType.res +133 -1
- package/src/components/Api/SchemaType.res.mjs +133 -8
- package/src/components/Api/SuryToJsonSchema.res +36 -0
- package/src/components/Api/SuryToJsonSchema.res.mjs +45 -0
- package/src/components/QueryDb/QueryDb_Operations.res +13 -0
- package/src/components/QueryDb/QueryDb_Operations.res.mjs +5 -0
- package/src/plugin/component/Plugin_Structure.res +9 -0
- package/src/plugin/component/Plugin_Structure.res.mjs +6 -4
- package/src/plugin/component/SchemaWalker.res +16 -3
- package/src/plugin/component/SchemaWalker.res.mjs +17 -6
- package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
- package/tests/admin/Platform_Admin_StructureTest.res +2 -2
- package/tests/api/GeolocationSemanticTest.res +108 -0
- package/tests/api/GeolocationSemanticTest.res.mjs +122 -0
- package/tests/api/TaggedUnionSchemaTest.res +198 -0
- package/tests/api/TaggedUnionSchemaTest.res.mjs +176 -0
- package/tests/fixtures/TaggedUnionFixtures.res +88 -0
- package/tests/fixtures/TaggedUnionFixtures.res.mjs +117 -0
- package/tests/querydb/TaggedUnionStampTest.res +163 -0
- package/tests/querydb/TaggedUnionStampTest.res.mjs +267 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
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.243 (2026-08-20)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **spec:** type a geocoder's answer as one Geolocation value ([157be7a](https://github.com/ReventlessDev/reventless-core/commit/157be7aca4806ae19dba1f58979af13b88dc1821))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.242 (2026-08-20)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **api:** emit a tagged-union state field as a GraphQL union ([3a380c0](https://github.com/ReventlessDev/reventless-core/commit/3a380c0ab055b87048d90a852ec1664f6aab6b00))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.241 (2026-08-19)
|
|
7
21
|
|
|
8
22
|
### 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.243",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -30,20 +30,20 @@
|
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
32
|
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
33
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
34
33
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
34
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
35
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
38
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
38
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.148",
|
|
39
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.120",
|
|
39
40
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
40
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.118",
|
|
41
41
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.32"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
45
45
|
"sury-ppx": "11.0.0-rc.1",
|
|
46
|
-
"@reventlessdev/reventless-ppx": "1.0.0-alpha.
|
|
46
|
+
"@reventlessdev/reventless-ppx": "1.0.0-alpha.72"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"rescript": "12.3.0"
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
open ReventlessInfra.Api
|
|
6
6
|
|
|
7
|
+
let log = Logger.fromEnv()
|
|
8
|
+
|
|
7
9
|
// Every aggregate-derived mutation returns the CommandResult union — the
|
|
8
10
|
// shape carries the outcome from CommandTopic processing (Accepted / Rejected
|
|
9
11
|
// for sync slices, Pending for async). Consumers select sub-fields via inline
|
|
@@ -85,6 +87,37 @@ let rec fromSchemaType = (
|
|
|
85
87
|
// reaches the UI through the field's JSON Schema, which is the channel that
|
|
86
88
|
// can express it.
|
|
87
89
|
| Semantic(_, inner) => fromSchemaType(~required, ~asInput, inner, collectedTypes, seenTypes)
|
|
90
|
+
// A union declares one object type per arm and a `union` naming them. Both
|
|
91
|
+
// names come from the schema — see `Reventless.TaggedUnion` — so the same union
|
|
92
|
+
// is the same type wherever it appears, which is what lets AppSync merge each
|
|
93
|
+
// source API's copy back into one, and what lets the write path stamp a
|
|
94
|
+
// `__typename` the SDL will recognise.
|
|
95
|
+
| TaggedUnion(name, arms) =>
|
|
96
|
+
if asInput {
|
|
97
|
+
// GraphQL has no input unions. A command that wants one takes its arms as
|
|
98
|
+
// separate mutations, which is what the command decomposition already
|
|
99
|
+
// does. Emitting the union type here would produce an SDL AppSync rejects
|
|
100
|
+
// outright, so the field keeps the `String` it renders as today — said out
|
|
101
|
+
// loud, because it is not what the declaration asks for.
|
|
102
|
+
log.warn(
|
|
103
|
+
~comp="GraphQL_FragmentGenerator",
|
|
104
|
+
`union "${name}" is used in an input position; GraphQL has no input unions, so the field is emitted as String. Take the arms as separate mutations instead.`,
|
|
105
|
+
)
|
|
106
|
+
`String${bang}`
|
|
107
|
+
} else {
|
|
108
|
+
if !(seenTypes->Set.has(name)) {
|
|
109
|
+
seenTypes->Set.add(name)
|
|
110
|
+
// The member types are collected by the recursion, which emits each arm
|
|
111
|
+
// as the object type it is. `~required=false` only so the reference comes
|
|
112
|
+
// back without a `!` — a union's member list carries no nullability.
|
|
113
|
+
let memberNames =
|
|
114
|
+
arms->Array.map(((_, armType)) =>
|
|
115
|
+
fromSchemaType(~required=false, ~asInput, armType, collectedTypes, seenTypes)
|
|
116
|
+
)
|
|
117
|
+
collectedTypes->Array.push(`union ${name} = ${memberNames->Array.join(" | ")}`)
|
|
118
|
+
}
|
|
119
|
+
`${name}${bang}`
|
|
120
|
+
}
|
|
88
121
|
| Unknown => `String${bang}`
|
|
89
122
|
}
|
|
90
123
|
}
|
|
@@ -124,12 +157,26 @@ let deriveFieldType = (
|
|
|
124
157
|
|
|
125
158
|
// ── Object type derivation ─────────────────────────────────────────────────
|
|
126
159
|
|
|
160
|
+
// A union field the walk cannot classify is emitted as `String`, and that has
|
|
161
|
+
// been true and silent for as long as the fallthrough has existed. Saying it here
|
|
162
|
+
// — at the one point every generated object type is derived — costs a walk of the
|
|
163
|
+
// schema and turns a field that will fail at execution into a line in the deploy
|
|
164
|
+
// log naming the type and the field.
|
|
165
|
+
let reportUnclassifiedUnions = (~typeName: string, schema: S.t<unknown>): unit =>
|
|
166
|
+
SchemaType.unclassifiedUnions(schema)->Array.forEach(({path, reason}) =>
|
|
167
|
+
log.warn(
|
|
168
|
+
~comp="GraphQL_FragmentGenerator",
|
|
169
|
+
`${typeName}.${path} is emitted as String: ${reason}`,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
127
173
|
let deriveObjectTypeWithNested = (
|
|
128
174
|
~typeName: string,
|
|
129
175
|
~excludeFields: array<string>=[],
|
|
130
176
|
~includeIdParam: bool=true,
|
|
131
177
|
schema: S.t<unknown>,
|
|
132
|
-
): array<string> =>
|
|
178
|
+
): array<string> => {
|
|
179
|
+
reportUnclassifiedUnions(~typeName, schema)
|
|
133
180
|
switch SchemaType.fromSuryObject(~typeName, schema) {
|
|
134
181
|
| Some(fields) =>
|
|
135
182
|
// `@internal` joins the caller's exclusions rather than replacing them. The
|
|
@@ -175,6 +222,7 @@ let deriveObjectTypeWithNested = (
|
|
|
175
222
|
Array.concat(collectedTypes, [mainTypeWithId])
|
|
176
223
|
| None => []
|
|
177
224
|
}
|
|
225
|
+
}
|
|
178
226
|
|
|
179
227
|
let derivePluralWrapperType = (~pluralTypeName: string, ~singularTypeName: string): string =>
|
|
180
228
|
`type ${pluralTypeName} {\n nextToken: String\n scannedCount: Int!\n items: [${singularTypeName}!]!\n}`
|
|
@@ -3,12 +3,15 @@
|
|
|
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 Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
6
7
|
import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
|
|
7
8
|
import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
|
|
8
9
|
import * as SchemaWalker$ReventlessCore from "../../plugin/component/SchemaWalker.res.mjs";
|
|
9
10
|
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
10
11
|
import * as GraphQL_Stitcher$ReventlessCore from "./GraphQL_Stitcher.res.mjs";
|
|
11
12
|
|
|
13
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
14
|
+
|
|
12
15
|
let commandResultSdlTypes = [
|
|
13
16
|
`union CommandResult = CommandAccepted | CommandRejected | CommandPending`,
|
|
14
17
|
`type CommandAccepted {
|
|
@@ -90,6 +93,18 @@ function fromSchemaType(_required, _asInputOpt, _st, collectedTypes, seenTypes)
|
|
|
90
93
|
_st = inner;
|
|
91
94
|
_asInputOpt = asInput;
|
|
92
95
|
continue;
|
|
96
|
+
case "TaggedUnion" :
|
|
97
|
+
let name$3 = st._0;
|
|
98
|
+
if (asInput) {
|
|
99
|
+
log.warn("GraphQL_FragmentGenerator", undefined, `union "` + name$3 + `" is used in an input position; GraphQL has no input unions, so the field is emitted as String. Take the arms as separate mutations instead.`);
|
|
100
|
+
return `String` + bang;
|
|
101
|
+
}
|
|
102
|
+
if (!seenTypes.has(name$3)) {
|
|
103
|
+
seenTypes.add(name$3);
|
|
104
|
+
let memberNames = st._1.map(param => fromSchemaType(false, asInput, param[1], collectedTypes, seenTypes));
|
|
105
|
+
collectedTypes.push(`union ` + name$3 + ` = ` + memberNames.join(" | "));
|
|
106
|
+
}
|
|
107
|
+
return name$3 + bang;
|
|
93
108
|
}
|
|
94
109
|
}
|
|
95
110
|
};
|
|
@@ -110,9 +125,14 @@ function deriveFieldType(parentTypeName, fieldName, required, fieldSchema, colle
|
|
|
110
125
|
return fromSchemaType(required, undefined, st, collectedTypes, seenTypes);
|
|
111
126
|
}
|
|
112
127
|
|
|
128
|
+
function reportUnclassifiedUnions(typeName, schema) {
|
|
129
|
+
SchemaType$ReventlessCore.unclassifiedUnions(schema).forEach(param => log.warn("GraphQL_FragmentGenerator", undefined, typeName + `.` + param.path + ` is emitted as String: ` + param.reason));
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOpt, schema) {
|
|
114
133
|
let excludeFields = excludeFieldsOpt !== undefined ? excludeFieldsOpt : [];
|
|
115
134
|
let includeIdParam = includeIdParamOpt !== undefined ? includeIdParamOpt : true;
|
|
135
|
+
reportUnclassifiedUnions(typeName, schema);
|
|
116
136
|
let fields = SchemaType$ReventlessCore.fromSuryObject(typeName, schema);
|
|
117
137
|
if (fields === undefined) {
|
|
118
138
|
return [];
|
|
@@ -580,10 +600,12 @@ function generate(mutationEntries, queryEntries) {
|
|
|
580
600
|
}
|
|
581
601
|
|
|
582
602
|
export {
|
|
603
|
+
log,
|
|
583
604
|
commandResultSdlTypes,
|
|
584
605
|
fromSchemaType,
|
|
585
606
|
objectRefToGraphQL,
|
|
586
607
|
deriveFieldType,
|
|
608
|
+
reportUnclassifiedUnions,
|
|
587
609
|
deriveObjectTypeWithNested,
|
|
588
610
|
derivePluralWrapperType,
|
|
589
611
|
deriveConnectionTypes,
|
|
@@ -611,4 +633,4 @@ export {
|
|
|
611
633
|
mutationArgTypes,
|
|
612
634
|
generate,
|
|
613
635
|
}
|
|
614
|
-
/*
|
|
636
|
+
/* log Not a pure module */
|
|
@@ -15,6 +15,14 @@ type rec schemaType =
|
|
|
15
15
|
are *not* expressed this way: they long predate the generic marker and
|
|
16
16
|
their JSON Schema `format` output is a published contract. */
|
|
17
17
|
Semantic(Reventless.Semantic.t, schemaType)
|
|
18
|
+
| /** A variant used as a field: the union's name, and one arm per constructor
|
|
19
|
+
keyed by the `TAG` sury discriminates on. Each arm is the `ObjectRef` its
|
|
20
|
+
member type is emitted from, so the arm's own name travels with it and
|
|
21
|
+
every consumer that can already render an object renders an arm.
|
|
22
|
+
|
|
23
|
+
Only a *named* union reaches this case — see `Reventless.TaggedUnion`,
|
|
24
|
+
which owns the name and the arm rules both. */
|
|
25
|
+
TaggedUnion(string, array<(string, schemaType)>)
|
|
18
26
|
| Unknown
|
|
19
27
|
|
|
20
28
|
let isTagged = Reventless.DcbTag.isTagged
|
|
@@ -162,7 +170,35 @@ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): s
|
|
|
162
170
|
let enum = Enum(enumName, constValues)
|
|
163
171
|
isOptional ? Nullable(enum) : enum
|
|
164
172
|
} else {
|
|
165
|
-
|
|
173
|
+
// A union of payload-carrying arms. Its name is read off the schema
|
|
174
|
+
// rather than composed from the field path the way the enum above is:
|
|
175
|
+
// the same union is one type wherever it appears, and the write-time
|
|
176
|
+
// `__typename` stamp has only the schema to derive it from.
|
|
177
|
+
switch Reventless.TaggedUnion.classify(schema) {
|
|
178
|
+
| Some((unionName, arms)) =>
|
|
179
|
+
let armTypes = arms->Array.map(({tag, schema: armSchema}) => {
|
|
180
|
+
let memberName = Reventless.TaggedUnion.memberTypeName(~union=unionName, ~arm=tag)
|
|
181
|
+
let fields = Dict.make()
|
|
182
|
+
switch armSchema {
|
|
183
|
+
| Object({properties}) =>
|
|
184
|
+
properties
|
|
185
|
+
->Dict.toArray
|
|
186
|
+
->Array.forEach(((propName, propSchema)) =>
|
|
187
|
+
if propName !== "TAG" {
|
|
188
|
+
fields->Dict.set(
|
|
189
|
+
propName,
|
|
190
|
+
fromSury(~parentName=memberName, ~fieldName=propName, propSchema),
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
| _ => ()
|
|
195
|
+
}
|
|
196
|
+
(tag, ObjectRef(memberName, fields))
|
|
197
|
+
})
|
|
198
|
+
let union = TaggedUnion(unionName, armTypes)
|
|
199
|
+
isOptional ? Nullable(union) : union
|
|
200
|
+
| None => Unknown
|
|
201
|
+
}
|
|
166
202
|
}
|
|
167
203
|
}
|
|
168
204
|
| Null(_) => Nullable(ScalarString)
|
|
@@ -171,6 +207,102 @@ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): s
|
|
|
171
207
|
}
|
|
172
208
|
}
|
|
173
209
|
|
|
210
|
+
/**
|
|
211
|
+
A field whose schema is a union the walk could not classify, and why.
|
|
212
|
+
|
|
213
|
+
`Unknown` is emitted as `String`, which is a lie a client only discovers at
|
|
214
|
+
execution time — and for a union field it is the *silent* half of a failure
|
|
215
|
+
whose loud half is a null in a non-nullable position. Reporting is deliberately
|
|
216
|
+
narrow: a field is only named here if its schema is a union of two or more real
|
|
217
|
+
members, which is a shape somebody wrote on purpose. An opaque `JSON.t` field
|
|
218
|
+
also lands on `Unknown` and is left alone, because nothing better was ever
|
|
219
|
+
available for it.
|
|
220
|
+
*/
|
|
221
|
+
type unclassifiedUnion = {path: string, reason: string}
|
|
222
|
+
|
|
223
|
+
// Walks the schema, not a value, so a self-referential schema has nothing to
|
|
224
|
+
// terminate it. Bounded rather than cycle-detected: this is a report, and a
|
|
225
|
+
// field nested a dozen records deep is past the depth the SDL emitter itself
|
|
226
|
+
// renders usefully.
|
|
227
|
+
let maxReportDepth = 12
|
|
228
|
+
|
|
229
|
+
let rec collectUnclassifiedUnions = (
|
|
230
|
+
~path: string,
|
|
231
|
+
~depth: int=0,
|
|
232
|
+
schema: S.t<unknown>,
|
|
233
|
+
out: array<unclassifiedUnion>,
|
|
234
|
+
): unit =>
|
|
235
|
+
if depth > maxReportDepth {
|
|
236
|
+
()
|
|
237
|
+
} else {
|
|
238
|
+
let under = (name: string) => path === "" ? name : path ++ "." ++ name
|
|
239
|
+
let recurse = (~path, schema) => collectUnclassifiedUnions(~path, ~depth=depth + 1, schema, out)
|
|
240
|
+
switch schema {
|
|
241
|
+
| Object({properties}) =>
|
|
242
|
+
properties
|
|
243
|
+
->Dict.toArray
|
|
244
|
+
->Array.forEach(((name, propSchema)) =>
|
|
245
|
+
if name !== "TAG" {
|
|
246
|
+
recurse(~path=under(name), propSchema)
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
| Array({items, additionalItems}) =>
|
|
250
|
+
let itemSchema = switch items->Array.get(0) {
|
|
251
|
+
| Some(s) => Some(s)
|
|
252
|
+
| None =>
|
|
253
|
+
switch additionalItems {
|
|
254
|
+
| Schema(s) => Some(s)
|
|
255
|
+
| _ => None
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
switch itemSchema {
|
|
259
|
+
| Some(s) => recurse(~path=path ++ "[]", s)
|
|
260
|
+
| None => ()
|
|
261
|
+
}
|
|
262
|
+
| AnyOf({anyOf}) =>
|
|
263
|
+
let members = anyOf->Array.filter(v =>
|
|
264
|
+
switch v {
|
|
265
|
+
| Null(_) | Undefined(_) => false
|
|
266
|
+
| _ => true
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
switch members {
|
|
270
|
+
| [inner] => recurse(~path, inner)
|
|
271
|
+
| _ =>
|
|
272
|
+
let allConst = members->Array.every(v =>
|
|
273
|
+
switch v {
|
|
274
|
+
| String({const: ?Some(_)}) => true
|
|
275
|
+
| _ => false
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
if allConst {
|
|
279
|
+
()
|
|
280
|
+
} else {
|
|
281
|
+
switch Reventless.TaggedUnion.classify(schema) {
|
|
282
|
+
| Some((_, arms)) =>
|
|
283
|
+
arms->Array.forEach(({tag, schema: armSchema}) => recurse(~path=under(tag), armSchema))
|
|
284
|
+
| None =>
|
|
285
|
+
let reason = if Reventless.TaggedUnion.armsOf(schema)->Option.isSome {
|
|
286
|
+
`its arms are well-formed but the union carries no name. Declare it in a spec file, where the ppx names it, or mark the schema with \`Reventless.TaggedUnion.named\`.`
|
|
287
|
+
} else {
|
|
288
|
+
`its ${members
|
|
289
|
+
->Array.length
|
|
290
|
+
->Int.toString} members are neither all string literals (an enum) nor all tagged objects each declaring at least one named field of its own (a union).`
|
|
291
|
+
}
|
|
292
|
+
out->Array.push({path, reason})
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
| _ => ()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let unclassifiedUnions = (schema: S.t<unknown>): array<unclassifiedUnion> => {
|
|
301
|
+
let out = []
|
|
302
|
+
collectUnclassifiedUnions(~path="", schema, out)
|
|
303
|
+
out
|
|
304
|
+
}
|
|
305
|
+
|
|
174
306
|
/**
|
|
175
307
|
The names of an object's optional properties, read from sury rather than from
|
|
176
308
|
the IR.
|
|
@@ -6,6 +6,7 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
|
|
|
6
6
|
import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
|
|
7
7
|
import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
|
|
8
8
|
import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
|
|
9
|
+
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
9
10
|
|
|
10
11
|
function isIdFieldName(name) {
|
|
11
12
|
let lower = name.toLowerCase();
|
|
@@ -147,28 +148,147 @@ function shapeOf(parentName, fieldName, schema) {
|
|
|
147
148
|
return c;
|
|
148
149
|
}
|
|
149
150
|
});
|
|
150
|
-
if (constValues.length
|
|
151
|
+
if (constValues.length === nonNullVariants.length && constValues.length !== 0) {
|
|
152
|
+
let enumName = parentName + fieldName.charAt(0).toUpperCase() + fieldName.slice(1, fieldName.length);
|
|
153
|
+
let $$enum = {
|
|
154
|
+
TAG: "Enum",
|
|
155
|
+
_0: enumName,
|
|
156
|
+
_1: constValues
|
|
157
|
+
};
|
|
158
|
+
if (isOptional) {
|
|
159
|
+
return {
|
|
160
|
+
TAG: "Nullable",
|
|
161
|
+
_0: $$enum
|
|
162
|
+
};
|
|
163
|
+
} else {
|
|
164
|
+
return $$enum;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let match = TaggedUnion$Reventless.classify(schema);
|
|
168
|
+
if (match === undefined) {
|
|
151
169
|
return "Unknown";
|
|
152
170
|
}
|
|
153
|
-
let
|
|
154
|
-
let
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
let unionName = match[0];
|
|
172
|
+
let armTypes = match[1].map(param => {
|
|
173
|
+
let armSchema = param.schema;
|
|
174
|
+
let tag = param.tag;
|
|
175
|
+
let memberName = TaggedUnion$Reventless.memberTypeName(unionName, tag);
|
|
176
|
+
let fields = {};
|
|
177
|
+
if (armSchema.type === "object") {
|
|
178
|
+
Object.entries(armSchema.properties).forEach(param => {
|
|
179
|
+
let propName = param[0];
|
|
180
|
+
if (propName !== "TAG") {
|
|
181
|
+
fields[propName] = fromSury(memberName, propName, param[1]);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return [
|
|
187
|
+
tag,
|
|
188
|
+
{
|
|
189
|
+
TAG: "ObjectRef",
|
|
190
|
+
_0: memberName,
|
|
191
|
+
_1: fields
|
|
192
|
+
}
|
|
193
|
+
];
|
|
194
|
+
});
|
|
195
|
+
let union = {
|
|
196
|
+
TAG: "TaggedUnion",
|
|
197
|
+
_0: unionName,
|
|
198
|
+
_1: armTypes
|
|
158
199
|
};
|
|
159
200
|
if (isOptional) {
|
|
160
201
|
return {
|
|
161
202
|
TAG: "Nullable",
|
|
162
|
-
_0:
|
|
203
|
+
_0: union
|
|
163
204
|
};
|
|
164
205
|
} else {
|
|
165
|
-
return
|
|
206
|
+
return union;
|
|
166
207
|
}
|
|
167
208
|
default:
|
|
168
209
|
return "Unknown";
|
|
169
210
|
}
|
|
170
211
|
}
|
|
171
212
|
|
|
213
|
+
function collectUnclassifiedUnions(path, depthOpt, schema, out) {
|
|
214
|
+
let depth = depthOpt !== undefined ? depthOpt : 0;
|
|
215
|
+
if (depth > 12) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
let under = name => {
|
|
219
|
+
if (path === "") {
|
|
220
|
+
return name;
|
|
221
|
+
} else {
|
|
222
|
+
return path + "." + name;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
let recurse = (path, schema) => collectUnclassifiedUnions(path, depth + 1 | 0, schema, out);
|
|
226
|
+
switch (schema.type) {
|
|
227
|
+
case "array" :
|
|
228
|
+
let additionalItems = schema.additionalItems;
|
|
229
|
+
let s = schema.items[0];
|
|
230
|
+
let itemSchema = s !== undefined ? s : (
|
|
231
|
+
additionalItems === "strip" || additionalItems === "strict" ? undefined : additionalItems
|
|
232
|
+
);
|
|
233
|
+
if (itemSchema !== undefined) {
|
|
234
|
+
return recurse(path + "[]", itemSchema);
|
|
235
|
+
} else {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
case "object" :
|
|
239
|
+
Object.entries(schema.properties).forEach(param => {
|
|
240
|
+
let name = param[0];
|
|
241
|
+
if (name !== "TAG") {
|
|
242
|
+
return recurse(under(name), param[1]);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
case "anyOf" :
|
|
247
|
+
let members = schema.anyOf.filter(v => {
|
|
248
|
+
switch (v.type) {
|
|
249
|
+
case "null" :
|
|
250
|
+
case "undefined" :
|
|
251
|
+
return false;
|
|
252
|
+
default:
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
if (members.length !== 1) {
|
|
257
|
+
let allConst = members.every(v => {
|
|
258
|
+
if (v.type === "string") {
|
|
259
|
+
return v.const !== undefined;
|
|
260
|
+
} else {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
if (allConst) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
let match = TaggedUnion$Reventless.classify(schema);
|
|
268
|
+
if (match !== undefined) {
|
|
269
|
+
match[1].forEach(param => recurse(under(param.tag), param.schema));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
let reason = Stdlib_Option.isSome(TaggedUnion$Reventless.armsOf(schema)) ? `its arms are well-formed but the union carries no name. Declare it in a spec file, where the ppx names it, or mark the schema with \`Reventless.TaggedUnion.named\`.` : `its ` + members.length.toString() + ` members are neither all string literals (an enum) nor all tagged objects each declaring at least one named field of its own (a union).`;
|
|
273
|
+
out.push({
|
|
274
|
+
path: path,
|
|
275
|
+
reason: reason
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
let inner = members[0];
|
|
280
|
+
return recurse(path, inner);
|
|
281
|
+
default:
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function unclassifiedUnions(schema) {
|
|
287
|
+
let out = [];
|
|
288
|
+
collectUnclassifiedUnions("", undefined, schema, out);
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
172
292
|
function optionalFieldNames(schema) {
|
|
173
293
|
if (schema.type === "object") {
|
|
174
294
|
return Stdlib_Array.filterMap(Object.entries(schema.properties), param => {
|
|
@@ -209,6 +329,8 @@ let isTagged = DcbTag$Reventless.isTagged;
|
|
|
209
329
|
|
|
210
330
|
let isDateTime = DateTime$Reventless.isDateTime;
|
|
211
331
|
|
|
332
|
+
let maxReportDepth = 12;
|
|
333
|
+
|
|
212
334
|
export {
|
|
213
335
|
isTagged,
|
|
214
336
|
isDateTime,
|
|
@@ -218,6 +340,9 @@ export {
|
|
|
218
340
|
canonicalName,
|
|
219
341
|
fromSury,
|
|
220
342
|
shapeOf,
|
|
343
|
+
maxReportDepth,
|
|
344
|
+
collectUnclassifiedUnions,
|
|
345
|
+
unclassifiedUnions,
|
|
221
346
|
optionalFieldNames,
|
|
222
347
|
fromSuryObject,
|
|
223
348
|
}
|
|
@@ -196,9 +196,45 @@ let rec fromSchemaType = (st: SchemaType.schemaType): JSON.t =>
|
|
|
196
196
|
("enum", JSON.Encode.array(values->Array.map(JSON.Encode.string))),
|
|
197
197
|
])
|
|
198
198
|
| Semantic(sem, inner) => fromSchemaType(inner)->withSemantic(sem)
|
|
199
|
+
// A union is `oneOf` its arms, each an object carrying the `TAG` const it is
|
|
200
|
+
// discriminated by. The discriminator is what tells a union apart from a
|
|
201
|
+
// nullable object, which is also a `oneOf` of objects — a reader that misses
|
|
202
|
+
// it selects one arm's fields as though they were the field's own and produces
|
|
203
|
+
// a query that looks plausible and is invalid.
|
|
204
|
+
| TaggedUnion(name, arms) =>
|
|
205
|
+
let members = arms->Array.map(((tag, armType)) => armToJsonSchema(~tag, armType))
|
|
206
|
+
jsonObject([("oneOf", JSON.Encode.array(members)), ("x-reventless-union", str(name))])
|
|
199
207
|
| Unknown => jsonObject([("type", str("string"))])
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
// One arm. `TAG` is declared rather than filtered out — the raw value on the
|
|
211
|
+
// wire and in the live change channel carries it, and it is the only thing in
|
|
212
|
+
// the payload that says which arm this is. The GraphQL member type name rides
|
|
213
|
+
// alongside so a reader mapping a raw payload to a selection does not have to
|
|
214
|
+
// re-derive the naming rule in a second repo.
|
|
215
|
+
and armToJsonSchema = (~tag: string, armType: SchemaType.schemaType): JSON.t =>
|
|
216
|
+
switch armType {
|
|
217
|
+
| ObjectRef(memberName, fields) =>
|
|
218
|
+
let base = objectRefToJsonSchema(fields)
|
|
219
|
+
switch base->JSON.Decode.object {
|
|
220
|
+
| Some(obj) =>
|
|
221
|
+
switch obj->Dict.get("properties")->Option.flatMap(JSON.Decode.object) {
|
|
222
|
+
| Some(props) =>
|
|
223
|
+
props->Dict.set("TAG", jsonObject([("type", str("string")), ("const", str(tag))]))
|
|
224
|
+
| None => ()
|
|
225
|
+
}
|
|
226
|
+
let required = switch obj->Dict.get("required")->Option.flatMap(JSON.Decode.array) {
|
|
227
|
+
| Some(existing) => Array.concat([str("TAG")], existing)
|
|
228
|
+
| None => [str("TAG")]
|
|
229
|
+
}
|
|
230
|
+
obj->Dict.set("required", JSON.Encode.array(required))
|
|
231
|
+
obj->Dict.set("x-reventless-union-member", str(memberName))
|
|
232
|
+
JSON.Encode.object(obj)
|
|
233
|
+
| None => base
|
|
234
|
+
}
|
|
235
|
+
| other => fromSchemaType(other)
|
|
236
|
+
}
|
|
237
|
+
|
|
202
238
|
// Attach a type-carried semantic to a field's JSON Schema.
|
|
203
239
|
//
|
|
204
240
|
// This is the type path's emission point, and it is deliberately *not*
|
|
@@ -246,10 +246,54 @@ function fromSchemaType(st) {
|
|
|
246
246
|
]);
|
|
247
247
|
case "Semantic" :
|
|
248
248
|
return withSemantic(fromSchemaType(st._1), st._0);
|
|
249
|
+
case "TaggedUnion" :
|
|
250
|
+
let members = st._1.map(param => armToJsonSchema(param[0], param[1]));
|
|
251
|
+
return Object.fromEntries([
|
|
252
|
+
[
|
|
253
|
+
"oneOf",
|
|
254
|
+
members
|
|
255
|
+
],
|
|
256
|
+
[
|
|
257
|
+
"x-reventless-union",
|
|
258
|
+
st._0
|
|
259
|
+
]
|
|
260
|
+
]);
|
|
249
261
|
}
|
|
250
262
|
}
|
|
251
263
|
}
|
|
252
264
|
|
|
265
|
+
function armToJsonSchema(tag, armType) {
|
|
266
|
+
if (typeof armType !== "object") {
|
|
267
|
+
return fromSchemaType(armType);
|
|
268
|
+
}
|
|
269
|
+
if (armType.TAG !== "ObjectRef") {
|
|
270
|
+
return fromSchemaType(armType);
|
|
271
|
+
}
|
|
272
|
+
let base = objectRefToJsonSchema(undefined, undefined, undefined, armType._1);
|
|
273
|
+
let obj = Stdlib_JSON.Decode.object(base);
|
|
274
|
+
if (obj === undefined) {
|
|
275
|
+
return base;
|
|
276
|
+
}
|
|
277
|
+
let props = Stdlib_Option.flatMap(obj["properties"], Stdlib_JSON.Decode.object);
|
|
278
|
+
if (props !== undefined) {
|
|
279
|
+
props["TAG"] = Object.fromEntries([
|
|
280
|
+
[
|
|
281
|
+
"type",
|
|
282
|
+
"string"
|
|
283
|
+
],
|
|
284
|
+
[
|
|
285
|
+
"const",
|
|
286
|
+
tag
|
|
287
|
+
]
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
let existing = Stdlib_Option.flatMap(obj["required"], Stdlib_JSON.Decode.array);
|
|
291
|
+
let required = existing !== undefined ? ["TAG"].concat(existing) : ["TAG"];
|
|
292
|
+
obj["required"] = required;
|
|
293
|
+
obj["x-reventless-union-member"] = armType._0;
|
|
294
|
+
return obj;
|
|
295
|
+
}
|
|
296
|
+
|
|
253
297
|
function withSemantic(fieldSchema, sem) {
|
|
254
298
|
let obj = Stdlib_JSON.Decode.object(fieldSchema);
|
|
255
299
|
if (obj === undefined) {
|
|
@@ -368,6 +412,7 @@ export {
|
|
|
368
412
|
mergeAnnotations,
|
|
369
413
|
isNullableType,
|
|
370
414
|
fromSchemaType,
|
|
415
|
+
armToJsonSchema,
|
|
371
416
|
withSemantic,
|
|
372
417
|
objectRefToJsonSchema,
|
|
373
418
|
deriveObjectSchema,
|
|
@@ -67,12 +67,24 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
|
|
|
67
67
|
})
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Every union value in the row carries the GraphQL member type it is, written
|
|
71
|
+
// once here rather than by each of the fourteen read doors across three
|
|
72
|
+
// backends — and, unlike a read-time stamp, it is also on the row the live
|
|
73
|
+
// change channel hands over as raw JSON. A view with no union field is walked
|
|
74
|
+
// and left exactly as it was.
|
|
75
|
+
let stampUnionMembers = dict =>
|
|
76
|
+
Reventless.TaggedUnion.stampInto(
|
|
77
|
+
~schema=ReadModelSpec.stateSchema->S.castToUnknown,
|
|
78
|
+
JSON.Encode.object(dict),
|
|
79
|
+
)
|
|
80
|
+
|
|
70
81
|
let save = async (id, state, saveMode, ttl) =>
|
|
71
82
|
switch state->Message.encode(ReadModelSpec.stateSchema)->JSON.Decode.object {
|
|
72
83
|
| Some(dict) =>
|
|
73
84
|
dict->Dict.set("id", id->Message.encode(ReadModelSpec.Id.schema))
|
|
74
85
|
injectSubId(dict, state)
|
|
75
86
|
injectCompositeIndexAttrs(dict)
|
|
87
|
+
stampUnionMembers(dict)
|
|
76
88
|
let json = JSON.Encode.object(dict)
|
|
77
89
|
await Ops.jsonOps.save(id->ReadModelSpec.Id.toString, json, saveMode, ttl)
|
|
78
90
|
| None =>
|
|
@@ -87,6 +99,7 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
|
|
|
87
99
|
dict->Dict.set("id", id->Message.encode(ReadModelSpec.Id.schema))
|
|
88
100
|
injectSubId(dict, state)
|
|
89
101
|
injectCompositeIndexAttrs(dict)
|
|
102
|
+
stampUnionMembers(dict)
|
|
90
103
|
let json = JSON.Encode.object(dict)
|
|
91
104
|
Ok(batch->Array.concat([(id->ReadModelSpec.Id.toString, json, ttl)]))
|
|
92
105
|
| None =>
|