@reventlessdev/reventless-core 3.0.0-alpha.244 → 3.0.0-alpha.246

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 CHANGED
@@ -3,6 +3,21 @@
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.246 (2026-08-21)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
11
+ * **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
12
+
13
+
14
+ # 3.0.0-alpha.245 (2026-08-20)
15
+
16
+ ### Features
17
+
18
+ * **core:** report a union value stored without its member type ([3a834b5](https://github.com/ReventlessDev/reventless-core/commit/3a834b51d1d9441f5dfcaebec5fac365063c905e))
19
+
20
+
6
21
  # 3.0.0-alpha.244 (2026-08-20)
7
22
 
8
23
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.244",
3
+ "version": "3.0.0-alpha.246",
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.1",
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.148",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.120",
41
- "@reventlessdev/reventless-interop": "3.0.0-alpha.32"
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.149",
40
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.33",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.121"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
- "sury-ppx": "11.0.0-rc.1",
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
- Array.concat(collectedTypes, [mainTypeWithId])
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 deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOpt, schema) {
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
- return collectedTypes.concat([mainTypeWithId]);
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";
@@ -1095,6 +1095,8 @@ module Make = (
1095
1095
  let subIdField = V.Spec.subIdConfig->Option.map(c => c.subIdField)
1096
1096
  let indexes = V.Spec.config.indexes
1097
1097
  let indexQueries = if indexes->Array.length > 0 {Some(indexes)} else {None}
1098
+ let resolved = Api_Naming.resolvedFieldsOfConfig(~plugin=name, V.Spec.config)
1099
+ let resolvedFields = if resolved->Array.length > 0 {Some(resolved)} else {None}
1098
1100
  {
1099
1101
  ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
1100
1102
  listFieldName: qn.listFieldName,
@@ -1107,6 +1109,7 @@ module Make = (
1107
1109
  connectionSpec: true,
1108
1110
  subIdField: ?subIdField,
1109
1111
  indexQueries: ?indexQueries,
1112
+ resolvedFields: ?resolvedFields,
1110
1113
  }
1111
1114
  })
1112
1115
 
@@ -603,6 +603,8 @@ function Make(DcbEventLogStorage) {
603
603
  let subIdField = Stdlib_Option.map(V.Spec.subIdConfig, c => c.subIdField);
604
604
  let indexes = V.Spec.config.indexes;
605
605
  let indexQueries = indexes.length !== 0 ? indexes : undefined;
606
+ let resolved = Api_Naming$ReventlessCore.resolvedFieldsOfConfig(name, V.Spec.config);
607
+ let resolvedFields = resolved.length !== 0 ? resolved : undefined;
606
608
  return {
607
609
  singleFieldName: qn.singleFieldName,
608
610
  listFieldName: qn.listFieldName,
@@ -614,7 +616,8 @@ function Make(DcbEventLogStorage) {
614
616
  includeIdParam: qn.includeIdParam,
615
617
  connectionSpec: true,
616
618
  subIdField: subIdField,
617
- indexQueries: indexQueries
619
+ indexQueries: indexQueries,
620
+ resolvedFields: resolvedFields
618
621
  };
619
622
  });
620
623
  let automationEntries = automationSlices.map(A => {
@@ -1,3 +1,5 @@
1
+ let log = Logger.fromEnv()
2
+
1
3
  module type Ops = {
2
4
  let jsonOps: QueryDb.operations<string, JSON.t>
3
5
  }
@@ -72,11 +74,53 @@ module Make = (ReadModelSpec: Reventless.ReadModel.Spec, Ops: Ops) => {
72
74
  // backends — and, unlike a read-time stamp, it is also on the row the live
73
75
  // change channel hands over as raw JSON. A view with no union field is walked
74
76
  // and left exactly as it was.
75
- let stampUnionMembers = dict =>
77
+ // The union fields this view declares, read once. Empty for most views, and
78
+ // the whole diagnostic below costs nothing when it is.
79
+ let declaredUnionFields =
80
+ Reventless.TaggedUnion.fieldsOf(ReadModelSpec.stateSchema->S.castToUnknown)
81
+
82
+ // Logged once per instantiation, and unconditionally: "none" has to be
83
+ // distinguishable from "never instantiated", because a schema that resolves no
84
+ // union fields and a stamp that failed to run leave the same stored row.
85
+ log.debug(
86
+ ~comp="QueryDb",
87
+ `${ReadModelSpec.name}: union fields ${switch declaredUnionFields {
88
+ | [] => "none"
89
+ | fields =>
90
+ fields->Array.map(((f, n)) => `${f}:${n->Option.getOr("<unnamed>")}`)->Array.join(", ")
91
+ }}`,
92
+ )
93
+
94
+ // A union value stored without `__typename` is the failure D1 exists to
95
+ // prevent, and it is silent: the member resolves to null, the null takes its
96
+ // non-nullable parent, and a list loses the row rather than erroring. Nothing
97
+ // downstream can tell that from "no such row", so it is reported here — the
98
+ // one place that knows both the schema and the row.
99
+ let reportUnstamped = dict =>
100
+ declaredUnionFields->Array.forEach(((field, unionName)) =>
101
+ switch dict->Dict.get(field)->Option.flatMap(JSON.Decode.object) {
102
+ | Some(value) if value->Dict.get("TAG")->Option.isSome =>
103
+ if value->Dict.get(Reventless.TaggedUnion.typenameKey)->Option.isNone {
104
+ log.warn(
105
+ ~comp="QueryDb",
106
+ `${ReadModelSpec.name}.${field}: stored a union value with no ${Reventless.TaggedUnion.typenameKey} ` ++
107
+ `(union ${unionName->Option.getOr("<unnamed>")}). The field will resolve to null and take its ` ++
108
+ `parent with it. Union fields seen on this spec: ${declaredUnionFields
109
+ ->Array.map(((f, _)) => f)
110
+ ->Array.join(", ")}`,
111
+ )
112
+ }
113
+ | _ => ()
114
+ }
115
+ )
116
+
117
+ let stampUnionMembers = dict => {
76
118
  Reventless.TaggedUnion.stampInto(
77
119
  ~schema=ReadModelSpec.stateSchema->S.castToUnknown,
78
120
  JSON.Encode.object(dict),
79
121
  )
122
+ reportUnstamped(dict)
123
+ }
80
124
 
81
125
  let save = async (id, state, saveMode, ttl) =>
82
126
  switch state->Message.encode(ReadModelSpec.stateSchema)->JSON.Decode.object {
@@ -7,9 +7,12 @@ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
7
7
  import * as Effect from "effect/Effect";
8
8
  import * as Stream from "effect/Stream";
9
9
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
10
+ import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
10
11
  import * as Message$ReventlessCore from "../../Message.res.mjs";
11
12
  import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
12
13
 
14
+ let log = Logger$ReventlessCore.fromEnv();
15
+
13
16
  function Make(ReadModelSpec) {
14
17
  return Ops => {
15
18
  let decode = (id, stateJson) => {
@@ -77,14 +80,30 @@ function Make(ReadModelSpec) {
77
80
  dict[attrName$1] = value$1;
78
81
  });
79
82
  };
80
- let stampUnionMembers = dict => TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
83
+ let declaredUnionFields = TaggedUnion$Reventless.fieldsOf(ReadModelSpec.stateSchema);
84
+ log.debug("QueryDb", undefined, ReadModelSpec.name + `: union fields ` + (
85
+ declaredUnionFields.length !== 0 ? declaredUnionFields.map(param => param[0] + `:` + Stdlib_Option.getOr(param[1], "<unnamed>")).join(", ") : "none"
86
+ ));
87
+ let reportUnstamped = dict => {
88
+ declaredUnionFields.forEach(param => {
89
+ let field = param[0];
90
+ let value = Stdlib_Option.flatMap(dict[field], Stdlib_JSON.Decode.object);
91
+ if (value !== undefined && Stdlib_Option.isSome(value["TAG"]) && Stdlib_Option.isNone(value[TaggedUnion$Reventless.typenameKey])) {
92
+ return log.warn("QueryDb", undefined, ReadModelSpec.name + `.` + field + `: stored a union value with no ` + TaggedUnion$Reventless.typenameKey + ` ` + (`(union ` + Stdlib_Option.getOr(param[1], "<unnamed>") + `). The field will resolve to null and take its `) + (`parent with it. Union fields seen on this spec: ` + declaredUnionFields.map(param => param[0]).join(", ")));
93
+ }
94
+ });
95
+ };
96
+ let stampUnionMembers = dict => {
97
+ TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
98
+ reportUnstamped(dict);
99
+ };
81
100
  let save = async (id, state, saveMode, ttl) => {
82
101
  let dict = Stdlib_JSON.Decode.object(Message$ReventlessCore.encode(state, ReadModelSpec.stateSchema));
83
102
  if (dict !== undefined) {
84
103
  dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
85
104
  injectSubId(dict, state);
86
105
  injectCompositeIndexAttrs(dict);
87
- TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
106
+ stampUnionMembers(dict);
88
107
  return await Ops.jsonOps.save(ReadModelSpec.Id.toString(id), dict, saveMode, ttl);
89
108
  } else {
90
109
  return {
@@ -110,7 +129,7 @@ function Make(ReadModelSpec) {
110
129
  dict["id"] = Message$ReventlessCore.encode(id, ReadModelSpec.Id.schema);
111
130
  injectSubId(dict, state);
112
131
  injectCompositeIndexAttrs(dict);
113
- TaggedUnion$Reventless.stampInto(ReadModelSpec.stateSchema, dict);
132
+ stampUnionMembers(dict);
114
133
  return {
115
134
  TAG: "Ok",
116
135
  _0: batch.concat([[
@@ -154,6 +173,8 @@ function Make(ReadModelSpec) {
154
173
  load: load,
155
174
  injectSubId: injectSubId,
156
175
  injectCompositeIndexAttrs: injectCompositeIndexAttrs,
176
+ declaredUnionFields: declaredUnionFields,
177
+ reportUnstamped: reportUnstamped,
157
178
  stampUnionMembers: stampUnionMembers,
158
179
  save: save,
159
180
  saveBatch: saveBatch,
@@ -165,6 +186,7 @@ function Make(ReadModelSpec) {
165
186
  }
166
187
 
167
188
  export {
189
+ log,
168
190
  Make,
169
191
  }
170
- /* effect/Effect Not a pure module */
192
+ /* log Not a pure module */
@@ -255,6 +255,7 @@ module Make = (
255
255
  // its SDL did not declare. The DCB and admin entries have populated this
256
256
  // all along; the omission here was the asymmetry, not the rule.
257
257
  let indexes = R.Spec.config.indexes
258
+ let resolvedFields = Api_Naming.resolvedFieldsOfConfig(~plugin=name, R.Spec.config)
258
259
  {
259
260
  ReventlessInfra.Api.singleFieldName: qn.singleFieldName,
260
261
  listFieldName: qn.listFieldName,
@@ -265,6 +266,7 @@ module Make = (
265
266
  connectionSpec: true,
266
267
  subIdField: ?subIdField,
267
268
  indexQueries: ?(indexes->Array.length > 0 ? Some(indexes) : None),
269
+ resolvedFields: ?(resolvedFields->Array.length > 0 ? Some(resolvedFields) : None),
268
270
  }
269
271
  })
270
272
 
@@ -196,6 +196,7 @@ function Make(Spec) {
196
196
  let qn = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(extra$1, R.Spec.name, undefined);
197
197
  let subIdField = Stdlib_Option.map(R.Spec.subIdConfig, c => c.subIdField);
198
198
  let indexes = R.Spec.config.indexes;
199
+ let resolvedFields = Api_Naming$ReventlessCore.resolvedFieldsOfConfig(extra$1, R.Spec.config);
199
200
  return {
200
201
  singleFieldName: qn.singleFieldName,
201
202
  listFieldName: qn.listFieldName,
@@ -205,7 +206,8 @@ function Make(Spec) {
205
206
  permission: R.Spec.authorization,
206
207
  connectionSpec: true,
207
208
  subIdField: subIdField,
208
- indexQueries: indexes.length !== 0 ? indexes : undefined
209
+ indexQueries: indexes.length !== 0 ? indexes : undefined,
210
+ resolvedFields: resolvedFields.length !== 0 ? resolvedFields : undefined
209
211
  };
210
212
  });
211
213
  let queryEntries = queryEntriesFromReadModels.concat(dcbResult.queryEntries);
@@ -572,3 +572,144 @@ describe("@internal keeps a field off the generated type", () => {
572
572
  ))
573
573
  })
574
574
  })
575
+
576
+ // `@resolves` / `@resolvesMany` add a field to a queryable's object type that the
577
+ // state record does not carry — the row on the other side of a foreign key. The
578
+ // PPX has written those configs into `config.idResolvers` / `config.idsResolvers`
579
+ // all along and nothing emitted the field, so every backend provisioned a
580
+ // resolver for a field its own schema never declared.
581
+
582
+ module ResolvedFields = {
583
+ @schema
584
+ type product = {productId: string, name: string}
585
+
586
+ @schema
587
+ type order = {orderId: string, productId: string, productIds: array<string>}
588
+
589
+ let entries = (
590
+ ~resolvedFields,
591
+ ~targetPermission: option<Reventless.Authorization.permission>=?,
592
+ ): array<ReventlessInfra.Api.querySchemaEntry> => [
593
+ {
594
+ singleFieldName: "Catalog_Order",
595
+ listFieldName: "Catalog_Orders",
596
+ returnTypeName: "Catalog_Order",
597
+ stateSchema: orderSchema->S.castToUnknown,
598
+ authorization: None,
599
+ resolvedFields: ?resolvedFields,
600
+ },
601
+ {
602
+ singleFieldName: "Catalog_Product",
603
+ listFieldName: "Catalog_Products",
604
+ returnTypeName: "Catalog_Product",
605
+ stateSchema: productSchema->S.castToUnknown,
606
+ authorization: None,
607
+ permission: ?targetPermission,
608
+ },
609
+ ]
610
+
611
+ let bothFields: array<ReventlessInfra.Api.resolvedFieldEntry> = [
612
+ {fieldName: "product", typeName: "Catalog_Product", multi: false},
613
+ {fieldName: "products", typeName: "Catalog_Product", multi: true},
614
+ ]
615
+ }
616
+
617
+ describe("@resolves / @resolvesMany reach the SDL", () => {
618
+ let refused = f =>
619
+ try {
620
+ let _ = f()
621
+ false
622
+ } catch {
623
+ | _ => true
624
+ }
625
+
626
+ let orderType = (~resolvedFields, ~targetPermission=?) =>
627
+ GraphQL_Stitcher.decode(
628
+ GraphQL_FragmentGenerator.generate(
629
+ ~mutationEntries=[],
630
+ ~queryEntries=ResolvedFields.entries(~resolvedFields, ~targetPermission?),
631
+ ),
632
+ ).types
633
+ ->Array.find(t => t->String.startsWith("type Catalog_Order "))
634
+ ->Option.getOr("")
635
+
636
+ testSync("the single form returns the target's type", () =>
637
+ expect(
638
+ orderType(~resolvedFields=Some(ResolvedFields.bothFields))->String.includes(
639
+ "product: Catalog_Product",
640
+ ),
641
+ )->toBe(true)
642
+ )
643
+
644
+ // A list of non-null items rather than `[T]`: the batch read drops ids it did
645
+ // not find instead of padding the gap with nulls, so the field is shorter.
646
+ testSync("the batch form returns a list of the target's type", () =>
647
+ expect(
648
+ orderType(~resolvedFields=Some(ResolvedFields.bothFields))->String.includes(
649
+ "products: [Catalog_Product!]",
650
+ ),
651
+ )->toBe(true)
652
+ )
653
+
654
+ testSync("the state's own fields are untouched", () => {
655
+ let sdl = orderType(~resolvedFields=Some(ResolvedFields.bothFields))
656
+ expect((sdl->String.includes("orderId:"), sdl->String.includes("id: ID!")))->toEqual((
657
+ true,
658
+ true,
659
+ ))
660
+ })
661
+
662
+ // The control: without the entry the type is exactly what it was, which is what
663
+ // made the annotation inert rather than wrong.
664
+ testSync("a view that declares none gets no extra field", () =>
665
+ expect(orderType(~resolvedFields=None)->String.includes("Catalog_Product"))->toBe(false)
666
+ )
667
+
668
+ // A plugin's document is validated standalone before the merge, so a field
669
+ // returning a name nothing declares fails the whole document — every type in
670
+ // it, not just this field. Refused where the declaration is, naming it.
671
+ testSync("a target this plugin does not expose is refused", () =>
672
+ expect(
673
+ refused(() =>
674
+ orderType(
675
+ ~resolvedFields=Some([{fieldName: "supplier", typeName: "Supply_Supplier", multi: false}]),
676
+ )
677
+ ),
678
+ )->toBe(true)
679
+ )
680
+
681
+ // A nested field is read through its parent, so it answers under the parent's
682
+ // rule. A target guarded more narrowly than the view that embeds it would be
683
+ // handed over by the wider door.
684
+ testSync("a target guarded differently from the parent is refused", () =>
685
+ expect(
686
+ refused(() =>
687
+ orderType(
688
+ ~resolvedFields=Some(ResolvedFields.bothFields),
689
+ ~targetPermission=AllowGroups(["Admin"]),
690
+ )
691
+ ),
692
+ )->toBe(true)
693
+ )
694
+
695
+ // A target open to everyone can only narrow, so it needs no refusal.
696
+ testSync("a target open to everyone is allowed under an authenticated parent", () =>
697
+ expect(
698
+ refused(() =>
699
+ orderType(~resolvedFields=Some(ResolvedFields.bothFields), ~targetPermission=AllowAnonymous)
700
+ ),
701
+ )->toBe(false)
702
+ )
703
+
704
+ testSync("a resolved field colliding with a state field is refused", () =>
705
+ expect(
706
+ refused(() =>
707
+ orderType(
708
+ ~resolvedFields=Some([
709
+ {fieldName: "productId", typeName: "Catalog_Product", multi: false},
710
+ ]),
711
+ )
712
+ ),
713
+ )->toBe(true)
714
+ )
715
+ })
@@ -246,7 +246,7 @@ globalThis.describe("semantic composites are named once, not once per field", ()
246
246
  globalThis.expect(fragmentTypes.some(t => t.includes("AddProductPrice"))).toBe(false);
247
247
  });
248
248
  globalThis.test("the output position keeps the bare name", () => {
249
- let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, Sury.$schema(s => ({
249
+ let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Catalog_Product", undefined, false, undefined, Sury.$schema(s => ({
250
250
  productId: s.m(Sury.string),
251
251
  price: s.m(Money$Reventless.schema)
252
252
  })));
@@ -430,7 +430,7 @@ globalThis.describe("the by-index door", () => {
430
430
  });
431
431
 
432
432
  globalThis.describe("@internal keeps a field off the generated type", () => {
433
- let types = excludeFields => GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Platform_Widget", excludeFields, undefined, PsInternalFieldsReadModel$ReventlessCore.stateSchema).join("\n");
433
+ let types = excludeFields => GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested("Platform_Widget", excludeFields, undefined, undefined, PsInternalFieldsReadModel$ReventlessCore.stateSchema).join("\n");
434
434
  globalThis.test("the declared fields are absent", () => {
435
435
  globalThis.expect([
436
436
  types(undefined).includes("eventCollector"),
@@ -471,6 +471,112 @@ globalThis.describe("@internal keeps a field off the generated type", () => {
471
471
  });
472
472
  });
473
473
 
474
+ let productSchema = Sury.$schema(s => ({
475
+ productId: s.m(Sury.string),
476
+ name: s.m(Sury.string)
477
+ }));
478
+
479
+ let orderSchema = Sury.$schema(s => ({
480
+ orderId: s.m(Sury.string),
481
+ productId: s.m(Sury.string),
482
+ productIds: s.m(Sury.array(Sury.string))
483
+ }));
484
+
485
+ function entries(resolvedFields, targetPermission) {
486
+ return [
487
+ {
488
+ singleFieldName: "Catalog_Order",
489
+ listFieldName: "Catalog_Orders",
490
+ returnTypeName: "Catalog_Order",
491
+ stateSchema: orderSchema,
492
+ authorization: undefined,
493
+ resolvedFields: resolvedFields
494
+ },
495
+ {
496
+ singleFieldName: "Catalog_Product",
497
+ listFieldName: "Catalog_Products",
498
+ returnTypeName: "Catalog_Product",
499
+ stateSchema: productSchema,
500
+ authorization: undefined,
501
+ permission: targetPermission
502
+ }
503
+ ];
504
+ }
505
+
506
+ let bothFields = [
507
+ {
508
+ fieldName: "product",
509
+ typeName: "Catalog_Product",
510
+ multi: false
511
+ },
512
+ {
513
+ fieldName: "products",
514
+ typeName: "Catalog_Product",
515
+ multi: true
516
+ }
517
+ ];
518
+
519
+ let ResolvedFields = {
520
+ productSchema: productSchema,
521
+ orderSchema: orderSchema,
522
+ entries: entries,
523
+ bothFields: bothFields
524
+ };
525
+
526
+ globalThis.describe("@resolves / @resolvesMany reach the SDL", () => {
527
+ let refused = f => {
528
+ try {
529
+ f();
530
+ return false;
531
+ } catch (exn) {
532
+ return true;
533
+ }
534
+ };
535
+ let orderType = (resolvedFields, targetPermission) => Stdlib_Option.getOr(GraphQL_Stitcher$ReventlessCore.decode(GraphQL_FragmentGenerator$ReventlessCore.generate([], entries(resolvedFields, targetPermission))).types.find(t => t.startsWith("type Catalog_Order ")), "");
536
+ globalThis.test("the single form returns the target's type", () => {
537
+ globalThis.expect(orderType(bothFields, undefined).includes("product: Catalog_Product")).toBe(true);
538
+ });
539
+ globalThis.test("the batch form returns a list of the target's type", () => {
540
+ globalThis.expect(orderType(bothFields, undefined).includes("products: [Catalog_Product!]")).toBe(true);
541
+ });
542
+ globalThis.test("the state's own fields are untouched", () => {
543
+ let sdl = orderType(bothFields, undefined);
544
+ globalThis.expect([
545
+ sdl.includes("orderId:"),
546
+ sdl.includes("id: ID!")
547
+ ]).toEqual([
548
+ true,
549
+ true
550
+ ]);
551
+ });
552
+ globalThis.test("a view that declares none gets no extra field", () => {
553
+ globalThis.expect(orderType(undefined, undefined).includes("Catalog_Product")).toBe(false);
554
+ });
555
+ globalThis.test("a target this plugin does not expose is refused", () => {
556
+ globalThis.expect(refused(() => orderType([{
557
+ fieldName: "supplier",
558
+ typeName: "Supply_Supplier",
559
+ multi: false
560
+ }], undefined))).toBe(true);
561
+ });
562
+ globalThis.test("a target guarded differently from the parent is refused", () => {
563
+ globalThis.expect(refused(() => orderType(bothFields, {
564
+ TAG: "AllowGroups",
565
+ _0: ["Admin"]
566
+ }))).toBe(true);
567
+ });
568
+ globalThis.test("a target open to everyone is allowed under an authenticated parent", () => {
569
+ globalThis.expect(refused(() => orderType(bothFields, "AllowAnonymous"))).toBe(false);
570
+ });
571
+ globalThis.test("a resolved field colliding with a state field is refused", () => {
572
+ globalThis.expect(refused(() => orderType([{
573
+ fieldName: "productId",
574
+ typeName: "Catalog_Product",
575
+ multi: false
576
+ }], undefined))).toBe(true);
577
+ });
578
+ });
579
+
474
580
  export {
475
581
  OrderSliceCmd,
476
582
  mutationFor,
@@ -482,5 +588,6 @@ export {
482
588
  RefDoorRow,
483
589
  queryFor,
484
590
  refDoorFragment,
591
+ ResolvedFields,
485
592
  }
486
593
  /* commandSchema Not a pure module */