@reventlessdev/reventless-core 3.0.0-alpha.245 → 3.0.0-alpha.247
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 +23 -0
- package/package.json +6 -6
- package/src/components/Api/Api_Naming.res +58 -0
- package/src/components/Api/Api_Naming.res.mjs +44 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res +61 -1
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +44 -3
- package/src/components/Api/GraphQL_SchemaInspector.res.mjs +2 -2
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +88 -8
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +86 -28
- package/src/components/Dcb/Dcb_Builder.res +3 -0
- package/src/components/Dcb/Dcb_Builder.res.mjs +4 -1
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res +108 -12
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs +89 -22
- package/src/plugin/component/Plugin_Builder.res +2 -0
- package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
- package/tests/api/GraphQL_FragmentGeneratorTest.res +141 -0
- package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +109 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
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.247 (2026-08-22)
|
|
7
|
+
|
|
8
|
+
* feat(core)!: a slice that has given up says so ([f5559ab](https://github.com/ReventlessDev/reventless-core/commit/f5559abede3a9cf2c9a48aadc69a666c46cc75bf))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* `onExhausted` is required on the Translation and Automation
|
|
13
|
+
module types. ReScript has no optional module-type fields, so the alternative
|
|
14
|
+
was a PPX-injected default — and defaulting this one to silence is the bug.
|
|
15
|
+
A slice now states what abandonment means for its domain, `None` included.
|
|
16
|
+
GeocodeCustomerAddress answers with MarkAddressUnresolvable; the others say
|
|
17
|
+
nothing, on purpose.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 3.0.0-alpha.246 (2026-08-21)
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
|
|
26
|
+
* **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
|
|
27
|
+
|
|
28
|
+
|
|
6
29
|
# 3.0.0-alpha.245 (2026-08-20)
|
|
7
30
|
|
|
8
31
|
### Features
|
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.247",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"sury": "11.0.0-rc.
|
|
29
|
+
"sury": "11.0.0-rc.2",
|
|
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",
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
39
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.150",
|
|
40
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.33",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.122"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
45
|
-
"sury-ppx": "11.0.0-rc.
|
|
45
|
+
"sury-ppx": "11.0.0-rc.2",
|
|
46
46
|
"@reventlessdev/reventless-ppx": "1.0.0-alpha.72"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
@@ -128,6 +128,64 @@ let queryFieldNamesForReadModel = (~plugin: string, ~name: string, ~connectionSp
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// The GraphQL type a queryable is exposed as, from its spec name alone. Both
|
|
132
|
+
// `queryFieldNamesFor*` above name it this way, so a `@resolves` target reached
|
|
133
|
+
// by spec name lands on the same type whichever kind declares it.
|
|
134
|
+
let returnTypeNameFor = (~plugin: string, ~name: string) =>
|
|
135
|
+
`${plugin}_${singularize(stripViewSuffix(name))}`
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
The cross-table fields `@resolves` / `@resolvesMany` add to a queryable's object
|
|
139
|
+
type, named for the SDL. `plugin` owns the declaring view.
|
|
140
|
+
|
|
141
|
+
A target in another plugin is refused rather than emitted: under merged-API
|
|
142
|
+
composition each plugin's document must be valid standalone, so a field
|
|
143
|
+
returning a type the plugin does not define fails the merge — and on AWS the
|
|
144
|
+
target table's `dynamodb:*` grant is attached to the *declaring* plugin's API
|
|
145
|
+
role, so even a schema that merged would be refused at read time.
|
|
146
|
+
*/
|
|
147
|
+
let resolvedFieldsOfConfig = (
|
|
148
|
+
~plugin: string,
|
|
149
|
+
config: Reventless.ReadModel.config,
|
|
150
|
+
): array<ReventlessInfra.Api.resolvedFieldEntry> => {
|
|
151
|
+
let assertSamePlugin = (~pluginName: option<string>, ~tableName: string, ~fieldName: string) =>
|
|
152
|
+
switch pluginName {
|
|
153
|
+
| Some(p) if p != plugin =>
|
|
154
|
+
JsError.throwWithMessage(
|
|
155
|
+
`Field \`${fieldName}\` resolves to "${tableName}" in plugin "${p}", but cross-plugin ` ++
|
|
156
|
+
`resolvers are not supported: a plugin's GraphQL document must be valid standalone, and ` ++
|
|
157
|
+
`"${p}"'s table is not readable by "${plugin}"'s API role. Query the other view directly, ` ++
|
|
158
|
+
`or project the fields you need into this one.`,
|
|
159
|
+
)
|
|
160
|
+
| _ => ()
|
|
161
|
+
}
|
|
162
|
+
let single = config.idResolvers->Array.map(({source, target}) => {
|
|
163
|
+
let (fieldName, multi) = switch source.resolvedField {
|
|
164
|
+
| Single(f) => (f, false)
|
|
165
|
+
| Multi(f) => (f, true)
|
|
166
|
+
}
|
|
167
|
+
assertSamePlugin(~pluginName=target.pluginName, ~tableName=target.tableName, ~fieldName)
|
|
168
|
+
{
|
|
169
|
+
ReventlessInfra.Api.fieldName,
|
|
170
|
+
typeName: returnTypeNameFor(~plugin, ~name=target.tableName),
|
|
171
|
+
multi,
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
let many = config.idsResolvers->Array.map(({source, target}) => {
|
|
175
|
+
assertSamePlugin(
|
|
176
|
+
~pluginName=target.pluginName,
|
|
177
|
+
~tableName=target.tableName,
|
|
178
|
+
~fieldName=source.resolvedField,
|
|
179
|
+
)
|
|
180
|
+
{
|
|
181
|
+
ReventlessInfra.Api.fieldName: source.resolvedField,
|
|
182
|
+
typeName: returnTypeNameFor(~plugin, ~name=target.tableName),
|
|
183
|
+
multi: true,
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
Array.concat(single, many)
|
|
187
|
+
}
|
|
188
|
+
|
|
131
189
|
let queryFieldNamesForStateView = (~plugin: string, ~viewName: string, ~connectionSpec: bool=true): queryNames => {
|
|
132
190
|
let entity = stripViewSuffix(viewName)
|
|
133
191
|
let singular = singularize(entity)
|
|
@@ -101,6 +101,48 @@ function queryFieldNamesForReadModel(plugin, name, connectionSpecOpt) {
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
function returnTypeNameFor(plugin, name) {
|
|
105
|
+
return plugin + `_` + singularize(stripViewSuffix(name));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolvedFieldsOfConfig(plugin, config) {
|
|
109
|
+
let assertSamePlugin = (pluginName, tableName, fieldName) => {
|
|
110
|
+
if (pluginName !== undefined && pluginName !== plugin) {
|
|
111
|
+
return Stdlib_JsError.throwWithMessage(`Field \`` + fieldName + `\` resolves to "` + tableName + `" in plugin "` + pluginName + `", but cross-plugin resolvers are not supported: a plugin's GraphQL document must be valid standalone, and ` + (`"` + pluginName + `"'s table is not readable by "` + plugin + `"'s API role. Query the other view directly, `) + `or project the fields you need into this one.`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
let single = config.idResolvers.map(param => {
|
|
115
|
+
let target = param.target;
|
|
116
|
+
let f = param.source.resolvedField;
|
|
117
|
+
let match;
|
|
118
|
+
match = f.TAG === "Single" ? [
|
|
119
|
+
f._0,
|
|
120
|
+
false
|
|
121
|
+
] : [
|
|
122
|
+
f._0,
|
|
123
|
+
true
|
|
124
|
+
];
|
|
125
|
+
let fieldName = match[0];
|
|
126
|
+
assertSamePlugin(target.pluginName, target.tableName, fieldName);
|
|
127
|
+
return {
|
|
128
|
+
fieldName: fieldName,
|
|
129
|
+
typeName: returnTypeNameFor(plugin, target.tableName),
|
|
130
|
+
multi: match[1]
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
let many = config.idsResolvers.map(param => {
|
|
134
|
+
let target = param.target;
|
|
135
|
+
let source = param.source;
|
|
136
|
+
assertSamePlugin(target.pluginName, target.tableName, source.resolvedField);
|
|
137
|
+
return {
|
|
138
|
+
fieldName: source.resolvedField,
|
|
139
|
+
typeName: returnTypeNameFor(plugin, target.tableName),
|
|
140
|
+
multi: true
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return single.concat(many);
|
|
144
|
+
}
|
|
145
|
+
|
|
104
146
|
function queryFieldNamesForStateView(plugin, viewName, connectionSpecOpt) {
|
|
105
147
|
let connectionSpec = connectionSpecOpt !== undefined ? connectionSpecOpt : true;
|
|
106
148
|
let entity = stripViewSuffix(viewName);
|
|
@@ -147,6 +189,8 @@ export {
|
|
|
147
189
|
sliceMutationFieldFor,
|
|
148
190
|
canonicalPlural,
|
|
149
191
|
queryFieldNamesForReadModel,
|
|
192
|
+
returnTypeNameFor,
|
|
193
|
+
resolvedFieldsOfConfig,
|
|
150
194
|
queryFieldNamesForStateView,
|
|
151
195
|
queryFieldNamesForSliceQueryDb,
|
|
152
196
|
adminField,
|
|
@@ -170,10 +170,18 @@ let reportUnclassifiedUnions = (~typeName: string, schema: S.t<unknown>): unit =
|
|
|
170
170
|
)
|
|
171
171
|
)
|
|
172
172
|
|
|
173
|
+
// The SDL line for one `@resolves` / `@resolvesMany` field. Both forms are
|
|
174
|
+
// nullable: the single form has no row when the foreign key names one that was
|
|
175
|
+
// never written, and the batch form drops missing ids rather than padding with
|
|
176
|
+
// nulls, so the list is shorter — never null inside.
|
|
177
|
+
let resolvedFieldSdl = ({fieldName, typeName, multi}: resolvedFieldEntry): string =>
|
|
178
|
+
multi ? ` ${fieldName}: [${typeName}!]` : ` ${fieldName}: ${typeName}`
|
|
179
|
+
|
|
173
180
|
let deriveObjectTypeWithNested = (
|
|
174
181
|
~typeName: string,
|
|
175
182
|
~excludeFields: array<string>=[],
|
|
176
183
|
~includeIdParam: bool=true,
|
|
184
|
+
~resolvedFields: array<resolvedFieldEntry>=[],
|
|
177
185
|
schema: S.t<unknown>,
|
|
178
186
|
): array<string> => {
|
|
179
187
|
reportUnclassifiedUnions(~typeName, schema)
|
|
@@ -219,7 +227,24 @@ let deriveObjectTypeWithNested = (
|
|
|
219
227
|
} else {
|
|
220
228
|
mainType
|
|
221
229
|
}
|
|
222
|
-
|
|
230
|
+
// A cross-table field shares the type with the state record's own fields, so
|
|
231
|
+
// a name already taken would emit the field twice and fail the whole document
|
|
232
|
+
// — for every plugin in the merge, not just this one. Refuse at build time,
|
|
233
|
+
// naming both halves.
|
|
234
|
+
resolvedFields->Array.forEach(({fieldName}) =>
|
|
235
|
+
if filteredFields->Dict.get(fieldName)->Option.isSome || (includeIdParam && fieldName == "id") {
|
|
236
|
+
JsError.throwWithMessage(
|
|
237
|
+
`${typeName}.${fieldName} is declared by @resolves/@resolvesMany and is already a field of the state record. Name the resolved field something the state does not use.`,
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
let mainTypeComplete = if resolvedFields->Array.length == 0 {
|
|
242
|
+
mainTypeWithId
|
|
243
|
+
} else {
|
|
244
|
+
let body = mainTypeWithId->String.slice(~start=0, ~end=mainTypeWithId->String.length - 1)
|
|
245
|
+
body ++ resolvedFields->Array.map(resolvedFieldSdl)->Array.join("\n") ++ "\n}"
|
|
246
|
+
}
|
|
247
|
+
Array.concat(collectedTypes, [mainTypeComplete])
|
|
223
248
|
| None => []
|
|
224
249
|
}
|
|
225
250
|
}
|
|
@@ -763,6 +788,40 @@ let generate = (
|
|
|
763
788
|
}
|
|
764
789
|
})
|
|
765
790
|
|
|
791
|
+
// Two things a cross-table field has to satisfy, both checked before anything
|
|
792
|
+
// is emitted so the report names the declaration rather than the merge:
|
|
793
|
+
//
|
|
794
|
+
// The type it returns has to be one this fragment defines. A plugin's document
|
|
795
|
+
// is validated standalone before it is merged, and a field returning a name
|
|
796
|
+
// nothing declares fails the whole document rather than just the field.
|
|
797
|
+
//
|
|
798
|
+
// And the target has to be guarded the way the parent is. A nested field is
|
|
799
|
+
// reached through its parent, so it answers under the parent's authorization —
|
|
800
|
+
// a target that guards itself differently would be handed over by the wider
|
|
801
|
+
// door, which is the kind of hole nothing downstream can see. Equal rules pass,
|
|
802
|
+
// and so does a target open to everyone, which can only narrow.
|
|
803
|
+
let permissionOf = (entry: querySchemaEntry) =>
|
|
804
|
+
entry.permission->Option.getOr(Reventless.Authorization.AllowAuthenticated)
|
|
805
|
+
queryEntries->Array.forEach(entry =>
|
|
806
|
+
entry.resolvedFields
|
|
807
|
+
->Option.getOr([])
|
|
808
|
+
->Array.forEach(({fieldName, typeName}) =>
|
|
809
|
+
switch queryEntries->Array.find(e => e.returnTypeName == typeName) {
|
|
810
|
+
| None =>
|
|
811
|
+
JsError.throwWithMessage(
|
|
812
|
+
`${entry.returnTypeName}.${fieldName} is declared by @resolves/@resolvesMany and resolves to "${typeName}", which this plugin does not expose as a queryable. The target must be a ReadModel or StateViewSlice of the same plugin, named by its spec name.`,
|
|
813
|
+
)
|
|
814
|
+
| Some(target) =>
|
|
815
|
+
let targetPermission = permissionOf(target)
|
|
816
|
+
if targetPermission != permissionOf(entry) && targetPermission != AllowAnonymous {
|
|
817
|
+
JsError.throwWithMessage(
|
|
818
|
+
`${entry.returnTypeName}.${fieldName} resolves to "${typeName}", which declares a different authorization. A cross-table field is read through its parent and would answer under ${entry.returnTypeName}'s rule, handing over rows ${typeName}'s own door withholds. Give the two views the same authorization, or query ${typeName} directly.`,
|
|
819
|
+
)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
|
|
766
825
|
queryEntries->Array.forEach(entry => {
|
|
767
826
|
let includeIdParam = entry.includeIdParam->Option.getOr(true)
|
|
768
827
|
let connectionSpec = entry.connectionSpec->Option.getOr(true)
|
|
@@ -776,6 +835,7 @@ let generate = (
|
|
|
776
835
|
~typeName=entry.returnTypeName,
|
|
777
836
|
~excludeFields,
|
|
778
837
|
~includeIdParam,
|
|
838
|
+
~resolvedFields=entry.resolvedFields->Option.getOr([]),
|
|
779
839
|
entry.stateSchema,
|
|
780
840
|
)
|
|
781
841
|
nestedTypes->Array.forEach(t => {
|
|
@@ -3,6 +3,8 @@
|
|
|
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 Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
7
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
6
8
|
import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
|
|
7
9
|
import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
|
|
8
10
|
import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
|
|
@@ -129,9 +131,20 @@ function reportUnclassifiedUnions(typeName, schema) {
|
|
|
129
131
|
SchemaType$ReventlessCore.unclassifiedUnions(schema).forEach(param => log.warn("GraphQL_FragmentGenerator", undefined, typeName + `.` + param.path + ` is emitted as String: ` + param.reason));
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
function
|
|
134
|
+
function resolvedFieldSdl(param) {
|
|
135
|
+
let typeName = param.typeName;
|
|
136
|
+
let fieldName = param.fieldName;
|
|
137
|
+
if (param.multi) {
|
|
138
|
+
return ` ` + fieldName + `: [` + typeName + `!]`;
|
|
139
|
+
} else {
|
|
140
|
+
return ` ` + fieldName + `: ` + typeName;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOpt, resolvedFieldsOpt, schema) {
|
|
133
145
|
let excludeFields = excludeFieldsOpt !== undefined ? excludeFieldsOpt : [];
|
|
134
146
|
let includeIdParam = includeIdParamOpt !== undefined ? includeIdParamOpt : true;
|
|
147
|
+
let resolvedFields = resolvedFieldsOpt !== undefined ? resolvedFieldsOpt : [];
|
|
135
148
|
reportUnclassifiedUnions(typeName, schema);
|
|
136
149
|
let fields = SchemaType$ReventlessCore.fromSuryObject(typeName, schema);
|
|
137
150
|
if (fields === undefined) {
|
|
@@ -158,7 +171,20 @@ function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOp
|
|
|
158
171
|
seenTypes.add(typeName);
|
|
159
172
|
let mainType = objectRefToGraphQL(undefined, typeName, filteredFields, collectedTypes, seenTypes);
|
|
160
173
|
let mainTypeWithId = includeIdParam ? mainType.replace(`type ` + typeName + ` {\n`, `type ` + typeName + ` implements Node {\n id: ID!\n`) : mainType;
|
|
161
|
-
|
|
174
|
+
resolvedFields.forEach(param => {
|
|
175
|
+
let fieldName = param.fieldName;
|
|
176
|
+
if (Stdlib_Option.isSome(filteredFields[fieldName]) || includeIdParam && fieldName === "id") {
|
|
177
|
+
return Stdlib_JsError.throwWithMessage(typeName + `.` + fieldName + ` is declared by @resolves/@resolvesMany and is already a field of the state record. Name the resolved field something the state does not use.`);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
let mainTypeComplete;
|
|
181
|
+
if (resolvedFields.length === 0) {
|
|
182
|
+
mainTypeComplete = mainTypeWithId;
|
|
183
|
+
} else {
|
|
184
|
+
let body = mainTypeWithId.slice(0, mainTypeWithId.length - 1 | 0);
|
|
185
|
+
mainTypeComplete = body + resolvedFields.map(resolvedFieldSdl).join("\n") + "\n}";
|
|
186
|
+
}
|
|
187
|
+
return collectedTypes.concat([mainTypeComplete]);
|
|
162
188
|
}
|
|
163
189
|
|
|
164
190
|
function derivePluralWrapperType(pluralTypeName, singularTypeName) {
|
|
@@ -504,6 +530,20 @@ function generate(mutationEntries, queryEntries) {
|
|
|
504
530
|
return;
|
|
505
531
|
}
|
|
506
532
|
});
|
|
533
|
+
queryEntries.forEach(entry => {
|
|
534
|
+
Stdlib_Option.getOr(entry.resolvedFields, []).forEach(param => {
|
|
535
|
+
let typeName = param.typeName;
|
|
536
|
+
let fieldName = param.fieldName;
|
|
537
|
+
let target = queryEntries.find(e => e.returnTypeName === typeName);
|
|
538
|
+
if (target === undefined) {
|
|
539
|
+
return Stdlib_JsError.throwWithMessage(entry.returnTypeName + `.` + fieldName + ` is declared by @resolves/@resolvesMany and resolves to "` + typeName + `", which this plugin does not expose as a queryable. The target must be a ReadModel or StateViewSlice of the same plugin, named by its spec name.`);
|
|
540
|
+
}
|
|
541
|
+
let targetPermission = Stdlib_Option.getOr(target.permission, "AllowAuthenticated");
|
|
542
|
+
if (Primitive_object.notequal(targetPermission, Stdlib_Option.getOr(entry.permission, "AllowAuthenticated")) && targetPermission !== "AllowAnonymous") {
|
|
543
|
+
return Stdlib_JsError.throwWithMessage(entry.returnTypeName + `.` + fieldName + ` resolves to "` + typeName + `", which declares a different authorization. A cross-table field is read through its parent and would answer under ` + entry.returnTypeName + `'s rule, handing over rows ` + typeName + `'s own door withholds. Give the two views the same authorization, or query ` + typeName + ` directly.`);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
});
|
|
507
547
|
queryEntries.forEach(entry => {
|
|
508
548
|
let includeIdParam = Stdlib_Option.getOr(entry.includeIdParam, true);
|
|
509
549
|
let connectionSpec = Stdlib_Option.getOr(entry.connectionSpec, true);
|
|
@@ -511,7 +551,7 @@ function generate(mutationEntries, queryEntries) {
|
|
|
511
551
|
seenTypes.add(entry.returnTypeName);
|
|
512
552
|
let fields = entry.excludeFields;
|
|
513
553
|
let excludeFields = fields !== undefined ? fields : [];
|
|
514
|
-
let nestedTypes = deriveObjectTypeWithNested(entry.returnTypeName, excludeFields, includeIdParam, entry.stateSchema);
|
|
554
|
+
let nestedTypes = deriveObjectTypeWithNested(entry.returnTypeName, excludeFields, includeIdParam, Stdlib_Option.getOr(entry.resolvedFields, []), entry.stateSchema);
|
|
515
555
|
nestedTypes.forEach(t => {
|
|
516
556
|
types.push(t);
|
|
517
557
|
});
|
|
@@ -606,6 +646,7 @@ export {
|
|
|
606
646
|
objectRefToGraphQL,
|
|
607
647
|
deriveFieldType,
|
|
608
648
|
reportUnclassifiedUnions,
|
|
649
|
+
resolvedFieldSdl,
|
|
609
650
|
deriveObjectTypeWithNested,
|
|
610
651
|
derivePluralWrapperType,
|
|
611
652
|
deriveConnectionTypes,
|
|
@@ -13,7 +13,7 @@ function inspectScalar(schema) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function inspectObjectType(typeName, schema) {
|
|
16
|
-
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, schema);
|
|
16
|
+
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, undefined, schema);
|
|
17
17
|
return types[types.length - 1 | 0];
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -42,7 +42,7 @@ function inspectMutationFields(fieldPrefix, commandSchema) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
function inspectQueryFields(name, typeName, stateSchema) {
|
|
45
|
-
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, stateSchema);
|
|
45
|
+
let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, undefined, stateSchema);
|
|
46
46
|
let typeDef = types[types.length - 1 | 0];
|
|
47
47
|
let singleQuery = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectQueryField(name, typeName, undefined, undefined);
|
|
48
48
|
let pluralName = name + "s";
|
|
@@ -18,7 +18,12 @@ type todoStatus =
|
|
|
18
18
|
| Pending
|
|
19
19
|
| Processing
|
|
20
20
|
| Completed
|
|
21
|
+
/** Failed and will be tried again. */
|
|
21
22
|
| Failed
|
|
23
|
+
/** Failed for the last time — the retry budget is spent and no pass will pick
|
|
24
|
+
this row up again. Distinct from `Failed` because the two are opposite
|
|
25
|
+
instructions to whoever is reading: wait, or intervene. */
|
|
26
|
+
| Abandoned
|
|
22
27
|
|
|
23
28
|
@schema
|
|
24
29
|
type todoRow = {
|
|
@@ -28,6 +33,11 @@ type todoRow = {
|
|
|
28
33
|
processedAt?: string,
|
|
29
34
|
completedAt?: string,
|
|
30
35
|
retryCount: int,
|
|
36
|
+
/** The ceiling `retryCount` is racing, copied from the Spec when the row is
|
|
37
|
+
created. Optional because rows written before this field existed are
|
|
38
|
+
rehydrated through `S.parseOrThrow`, and a required field would fail that
|
|
39
|
+
decode — silently, since the restore swallows it per row. */
|
|
40
|
+
maxRetries?: int,
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
/** The item schema seen through the `JSON.t` the row actually stores.
|
|
@@ -58,6 +68,7 @@ let todoRowSchemaFor = (itemSchema: S.t<'item>): S.t<todoRow> =>
|
|
|
58
68
|
processedAt: ?s.matches(S.option(S.string)),
|
|
59
69
|
completedAt: ?s.matches(S.option(S.string)),
|
|
60
70
|
retryCount: s.matches(S.int),
|
|
71
|
+
maxRetries: ?s.matches(S.option(S.int)),
|
|
61
72
|
})
|
|
62
73
|
|
|
63
74
|
/**
|
|
@@ -122,6 +133,39 @@ module Make = (
|
|
|
122
133
|
// the slice here makes the target's own events unmatchable, silently.
|
|
123
134
|
let makeMeta = (): Reventless.Message.meta => Message.generateMeta(~service=Spec.targetName)
|
|
124
135
|
|
|
136
|
+
/** Record a failed attempt. The row is marked `Abandoned` at the moment the
|
|
137
|
+
budget runs out rather than being left to fall out of the next pass's
|
|
138
|
+
filter, so a reader never has to compare `retryCount` against a constant
|
|
139
|
+
that lives only in this Spec. */
|
|
140
|
+
let afterFailedAttempt = (row: todoRow): todoRow => {
|
|
141
|
+
let retryCount = row.retryCount + 1
|
|
142
|
+
{...row, status: retryCount >= Spec.maxRetries ? Abandoned : Failed, retryCount}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The command a slice wants published now that this item will never be
|
|
146
|
+
processed again, if it wants one. The row is `Abandoned` either way. */
|
|
147
|
+
let abandonmentCommand = (~id: string, ~row: todoRow): option<Reventless.Message.commandJson> =>
|
|
148
|
+
switch row.item->Reventless.Util_Sury.fromJson(Spec.todoItemSchema) {
|
|
149
|
+
| item =>
|
|
150
|
+
Automation.onExhausted(id, item)->Option.flatMap(((targetId, command)) =>
|
|
151
|
+
switch command->Reventless.Util_Sury.toJson(Spec.commandSchema) {
|
|
152
|
+
| commandJson => Some({Reventless.Message.id: targetId, meta: makeMeta(), commandJson})
|
|
153
|
+
| exception _ =>
|
|
154
|
+
EffectLogger.logError(
|
|
155
|
+
~comp=`AutomationSlice(${Spec.name})`,
|
|
156
|
+
`retries exhausted for ${id}, and the abandonment command could not be encoded`,
|
|
157
|
+
)->Effect.runSync
|
|
158
|
+
None
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
| exception _ =>
|
|
162
|
+
EffectLogger.logError(
|
|
163
|
+
~comp=`AutomationSlice(${Spec.name})`,
|
|
164
|
+
`retries exhausted for ${id}, and its item could not be decoded to announce it`,
|
|
165
|
+
)->Effect.runSync
|
|
166
|
+
None
|
|
167
|
+
}
|
|
168
|
+
|
|
125
169
|
// Per-source erased dispatch — pre-compile decoders once at module init.
|
|
126
170
|
type dispatch = {
|
|
127
171
|
sourceName: string,
|
|
@@ -146,6 +190,7 @@ module Make = (
|
|
|
146
190
|
status: Pending,
|
|
147
191
|
createdAt: now(),
|
|
148
192
|
retryCount: 0,
|
|
193
|
+
maxRetries: Spec.maxRetries,
|
|
149
194
|
}
|
|
150
195
|
todoItems->Dict.set(id, row)
|
|
151
196
|
}
|
|
@@ -187,6 +232,20 @@ module Make = (
|
|
|
187
232
|
}
|
|
188
233
|
|
|
189
234
|
let phase2 = async (publishJsons: ReventlessInfra.CommandTopic.publishJsons) => {
|
|
235
|
+
// A `Failed` row at or past the ceiling is one an older build left stranded,
|
|
236
|
+
// or one whose Spec lowered `maxRetries` under it. Either way it is abandoned
|
|
237
|
+
// in fact, so say so rather than letting it sit in a status that promises a
|
|
238
|
+
// retry no pass will make.
|
|
239
|
+
let newlyAbandoned: array<string> = []
|
|
240
|
+
todoItems
|
|
241
|
+
->Dict.toArray
|
|
242
|
+
->Array.forEach(((id, row)) =>
|
|
243
|
+
if row.status == Failed && row.retryCount >= Spec.maxRetries {
|
|
244
|
+
todoItems->Dict.set(id, {...row, status: Abandoned})
|
|
245
|
+
newlyAbandoned->Array.push(id)
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
190
249
|
let pending =
|
|
191
250
|
todoItems
|
|
192
251
|
->Dict.toArray
|
|
@@ -222,10 +281,11 @@ module Make = (
|
|
|
222
281
|
~comp=`AutomationSlice(${Spec.name})`,
|
|
223
282
|
`failed to encode command: ${errMsg}`,
|
|
224
283
|
)->Effect.runSync
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
284
|
+
let updated = row->afterFailedAttempt
|
|
285
|
+
todoItems->Dict.set(id, updated)
|
|
286
|
+
if updated.status == Abandoned {
|
|
287
|
+
newlyAbandoned->Array.push(id)
|
|
288
|
+
}
|
|
229
289
|
None
|
|
230
290
|
}
|
|
231
291
|
commandJson->Option.forEach(
|
|
@@ -256,14 +316,34 @@ module Make = (
|
|
|
256
316
|
pending->Array.forEach(((id, row)) => {
|
|
257
317
|
switch todoItems->Dict.get(id) {
|
|
258
318
|
| Some(current) if current.status == Processing =>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
319
|
+
let updated = row->afterFailedAttempt
|
|
320
|
+
todoItems->Dict.set(id, updated)
|
|
321
|
+
if updated.status == Abandoned {
|
|
322
|
+
newlyAbandoned->Array.push(id)
|
|
323
|
+
}
|
|
263
324
|
| _ => ()
|
|
264
325
|
}
|
|
265
326
|
})
|
|
266
327
|
}
|
|
267
328
|
}
|
|
329
|
+
|
|
330
|
+
// Announced after the batch, so a publish failure above has already had its
|
|
331
|
+
// say on which rows ran out of budget. A failure here cannot mark anything for
|
|
332
|
+
// retry — the budget is what ran out — so the rows stay `Abandoned` regardless.
|
|
333
|
+
let abandonmentCommands =
|
|
334
|
+
newlyAbandoned->Array.filterMap(id =>
|
|
335
|
+
todoItems->Dict.get(id)->Option.flatMap(row => abandonmentCommand(~id, ~row))
|
|
336
|
+
)
|
|
337
|
+
if abandonmentCommands->Array.length > 0 {
|
|
338
|
+
try await publishJsons(abandonmentCommands) catch {
|
|
339
|
+
| exn =>
|
|
340
|
+
let errMsg =
|
|
341
|
+
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
|
|
342
|
+
EffectLogger.logError(
|
|
343
|
+
~comp=`AutomationSlice(${Spec.name})`,
|
|
344
|
+
`failed to publish abandonment commands: ${errMsg}`,
|
|
345
|
+
)->Effect.runSync
|
|
346
|
+
}
|
|
347
|
+
}
|
|
268
348
|
}
|
|
269
349
|
}
|