@reventlessdev/reventless-aws 3.0.0-alpha.176 → 3.0.0-alpha.177

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.
@@ -19,12 +19,28 @@
19
19
 
20
20
  let log = ReventlessCore.Logger.fromEnv()
21
21
 
22
+ @val external atob: string => string = "atob"
23
+
24
+ // Where a cross-table resolver reads the target's sort-key value from.
25
+ // `kind`: "field" (the parent object) | "arg" (the GraphQL arguments).
26
+ type subIdSource = {kind: string, name: string}
27
+
22
28
  // Wire payload from the resolver template. `arguments` mirrors the field name the
23
29
  // other Invoke templates use (QueryInterceptor_Lambda, invokeCommandGenerator).
30
+ // The cross-table fields (B3.2c) are present only for resolveOne/resolveMany:
24
31
  type payload = {
25
32
  readModelName: string,
26
33
  kind: string,
27
34
  index?: string,
35
+ // Cross-table (@resolves/@resolvesMany) — see B3.2c dispatch below.
36
+ target?: string,
37
+ source?: JSON.t,
38
+ sourceIdField?: string,
39
+ sourceIdsField?: string,
40
+ sourceSubId?: subIdSource,
41
+ targetIndex?: string,
42
+ targetIndexIdField?: string,
43
+ multi?: bool,
28
44
  arguments: JSON.t,
29
45
  identity: Reventless.Identity.t,
30
46
  }
@@ -40,6 +56,13 @@ type pushdowns = {
40
56
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
41
57
  ~labelField: string,
42
58
  ) => promise<option<JSON.t>>,
59
+ // Sub-id connection ({single}Items) — keyset over sub_key within a partition.
60
+ itemsPage: (
61
+ ~readModelName: string,
62
+ ~subIdField: string,
63
+ ~id: string,
64
+ ~argsDict: dict<JSON.t>,
65
+ ) => promise<JSON.t>,
43
66
  // Full materialisation for the list fallback (shapes listPage declines).
44
67
  scanAll: (~readModelName: string) => promise<array<JSON.t>>,
45
68
  }
@@ -111,10 +134,12 @@ let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
111
134
  let rm = payload.readModelName
112
135
  switch await runInterceptor(~binding, ~payload) {
113
136
  | Deny(_) =>
114
- // Empty shape per kind (null for single, [] for lists, empty connection).
137
+ // Empty shape per kind (null for single, connection for list/items, [] else).
138
+ let isMulti = payload.multi->Option.getOr(false)
115
139
  switch payload.kind {
116
140
  | "getById" => JSON.Encode.null
117
- | "list" => emptyConnection()
141
+ | "resolveOne" if !isMulti => JSON.Encode.null
142
+ | "list" | "items" => emptyConnection()
118
143
  | _ => JSON.Encode.array([])
119
144
  }
120
145
  | Allow =>
@@ -134,6 +159,21 @@ let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
134
159
  let ids = payload.arguments->argStrs("ids")
135
160
  JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=rm, ids))
136
161
 
162
+ | "items" =>
163
+ // Sub-id connection: {single}Items(id, filter, first/after/last/before).
164
+ // Requires a subIdField; without one it's an empty connection.
165
+ switch binding.subIdField {
166
+ | Some(subIdField) =>
167
+ let id = payload.arguments->argStr("id")->Option.getOr("")
168
+ await binding.pushdowns.itemsPage(
169
+ ~readModelName=rm,
170
+ ~subIdField,
171
+ ~id,
172
+ ~argsDict=payload.arguments->argObj,
173
+ )
174
+ | None => emptyConnection()
175
+ }
176
+
137
177
  | "index" =>
138
178
  // The index arg name is the index; its stored field is idField ?? index.
139
179
  let indexName = payload.index->Option.getOr("")
@@ -169,6 +209,55 @@ let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
169
209
  )
170
210
  }
171
211
 
212
+ // Cross-table single-ID field resolver (@resolves). `binding` is the TARGET
213
+ // binding (the handler looks it up by payload.target); the key comes from the
214
+ // parent object (payload.source[sourceIdField]).
215
+ | "resolveOne" =>
216
+ let target = payload.target->Option.getOr(rm)
217
+ let source = payload.source->Option.getOr(JSON.Encode.null)
218
+ let key = source->argStr(payload.sourceIdField->Option.getOr(""))->Option.getOr("")
219
+ let items = switch payload.targetIndex {
220
+ | Some(ix) =>
221
+ await binding.pushdowns.indexLookup(
222
+ ~readModelName=target,
223
+ payload.targetIndexIdField->Option.getOr(ix),
224
+ key,
225
+ )
226
+ | None =>
227
+ switch await binding.ops.load(key) {
228
+ | Ok(items) => items
229
+ | Error(_) => []
230
+ }
231
+ }
232
+ // Optional target sort-key filter (source field or GraphQL arg).
233
+ let filtered = switch (payload.sourceSubId, binding.subIdField) {
234
+ | (Some({kind, name}), Some(subField)) =>
235
+ let subVal =
236
+ switch kind {
237
+ | "arg" => payload.arguments->argStr(name)
238
+ | _ => source->argStr(name)
239
+ }->Option.getOr("")
240
+ items->Array.filter(it => it->argStr(subField)->Option.getOr("") == subVal)
241
+ | _ => items
242
+ }
243
+ if payload.multi->Option.getOr(false) {
244
+ JSON.Encode.array(filtered)
245
+ } else {
246
+ switch filtered->Array.get(0) {
247
+ | Some(item) => item
248
+ | None => JSON.Encode.null
249
+ }
250
+ }
251
+
252
+ // Cross-table batch field resolver (@resolvesMany). ids come from the parent
253
+ // object (payload.source[sourceIdsField]); BatchGet the target by partition
254
+ // key (missing ids drop out).
255
+ | "resolveMany" =>
256
+ let target = payload.target->Option.getOr(rm)
257
+ let source = payload.source->Option.getOr(JSON.Encode.null)
258
+ let ids = source->argStrs(payload.sourceIdsField->Option.getOr(""))
259
+ JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=target, ids))
260
+
172
261
  | other =>
173
262
  // Fail loudly on unmapped kinds (feature-parity guard, per the B3.2 plan).
174
263
  log.error(~comp="PgQueryResolver_Lambda", `unmapped resolver kind '${other}' for ${rm}`)
@@ -182,11 +271,65 @@ let bindings: dict<binding> = Dict.make()
182
271
  let register = (~readModelName: string, binding: binding): unit =>
183
272
  bindings->Dict.set(readModelName, binding)
184
273
 
185
- let handler = async (payload: payload, _context) =>
186
- switch bindings->Dict.get(payload.readModelName) {
187
- | Some(binding) => await dispatch(~binding, ~payload)
188
- | None =>
189
- JsError.throwWithMessage(
190
- "PgQueryResolver: no binding registered for read model " ++ payload.readModelName,
191
- )
274
+ // Relay node type read-model-name map (B3.2c), populated at Lambda init from
275
+ // the deploy-time registerNodeType calls.
276
+ let nodeTypeMap: dict<string> = Dict.make()
277
+ let registerNodeType = (~typeName: string, ~readModelName: string): unit =>
278
+ nodeTypeMap->Dict.set(typeName, readModelName)
279
+
280
+ // node(id: ID!) — decode the global id (base64 of `typeName:localId`, matching
281
+ // AppSync's nodeDecodeGlobalId), map typeName → read model, load by localId,
282
+ // return the item tagged with __typename and the original global id. Runs the
283
+ // target's authorization/interceptor. Self-contained (getById returns raw ids,
284
+ // so no encoding elsewhere is affected).
285
+ let handleNode = async (~payload: payload): JSON.t => {
286
+ let globalId = payload.arguments->argStr("id")->Option.getOr("")
287
+ let decoded = try atob(globalId) catch {
288
+ | _ => ""
289
+ }
290
+ let colonIdx = decoded->String.indexOf(":")
291
+ if colonIdx <= 0 {
292
+ JSON.Encode.null
293
+ } else {
294
+ let typeName = decoded->String.slice(~start=0, ~end=colonIdx)
295
+ let localId = decoded->String.slice(~start=colonIdx + 1, ~end=decoded->String.length)
296
+ switch nodeTypeMap->Dict.get(typeName)->Option.flatMap(rm => bindings->Dict.get(rm)) {
297
+ | Some(binding) =>
298
+ switch await runInterceptor(~binding, ~payload) {
299
+ | Deny(_) => JSON.Encode.null
300
+ | Allow =>
301
+ switch await binding.ops.load(localId) {
302
+ | Ok(items) =>
303
+ switch items->Array.get(0) {
304
+ | Some(item) =>
305
+ let obj = item->JSON.Decode.object->Option.getOr(Dict.make())->Dict.copy
306
+ obj->Dict.set("__typename", JSON.Encode.string(typeName))
307
+ obj->Dict.set("id", JSON.Encode.string(globalId))
308
+ JSON.Encode.object(obj)
309
+ | None => JSON.Encode.null
310
+ }
311
+ | Error(_) => JSON.Encode.null
312
+ }
313
+ }
314
+ | None => JSON.Encode.null
315
+ }
316
+ }
317
+ }
318
+
319
+ let handler = async (payload: payload, _context) => {
320
+ // node decodes its own target; the cross-table field resolvers dispatch against
321
+ // the TARGET binding; everything else against the payload's own read model.
322
+ let bindingKey = switch payload.kind {
323
+ | "resolveOne" | "resolveMany" => payload.target->Option.getOr(payload.readModelName)
324
+ | _ => payload.readModelName
192
325
  }
326
+ switch payload.kind {
327
+ | "node" => await handleNode(~payload)
328
+ | _ =>
329
+ switch bindings->Dict.get(bindingKey) {
330
+ | Some(binding) => await dispatch(~binding, ~payload)
331
+ | None =>
332
+ JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " ++ bindingKey)
333
+ }
334
+ }
335
+ }
@@ -85,6 +85,13 @@ async function dispatch(binding, payload) {
85
85
  let field = Stdlib_Option.getOr(Stdlib_Option.flatMap(binding.indexes.find(ic => ic.index === indexName), ic => ic.idField), indexName);
86
86
  let value = Stdlib_Option.getOr(argStr(payload.arguments, indexName), "");
87
87
  return await binding.pushdowns.indexLookup(rm, field, value);
88
+ case "items" :
89
+ let subIdField = binding.subIdField;
90
+ if (subIdField === undefined) {
91
+ return emptyConnection();
92
+ }
93
+ let id$1 = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
94
+ return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments));
88
95
  case "list" :
89
96
  let argsDict = argObj(payload.arguments);
90
97
  let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField);
@@ -93,17 +100,62 @@ async function dispatch(binding, payload) {
93
100
  }
94
101
  let items$1 = await binding.pushdowns.scanAll(rm);
95
102
  return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, param => {});
103
+ case "resolveMany" :
104
+ let target = Stdlib_Option.getOr(payload.target, rm);
105
+ let source = Stdlib_Option.getOr(payload.source, null);
106
+ let ids$1 = argStrs(source, Stdlib_Option.getOr(payload.sourceIdsField, ""));
107
+ return await binding.pushdowns.byIds(target, ids$1);
108
+ case "resolveOne" :
109
+ let target$1 = Stdlib_Option.getOr(payload.target, rm);
110
+ let source$1 = Stdlib_Option.getOr(payload.source, null);
111
+ let key = Stdlib_Option.getOr(argStr(source$1, Stdlib_Option.getOr(payload.sourceIdField, "")), "");
112
+ let ix = payload.targetIndex;
113
+ let items$2;
114
+ if (ix !== undefined) {
115
+ items$2 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
116
+ } else {
117
+ let items$3 = await binding.ops.load(key);
118
+ items$2 = items$3.TAG === "Ok" ? items$3._0 : [];
119
+ }
120
+ let match$1 = payload.sourceSubId;
121
+ let match$2 = binding.subIdField;
122
+ let filtered;
123
+ if (match$1 !== undefined && match$2 !== undefined) {
124
+ let name = match$1.name;
125
+ let tmp = match$1.kind === "arg" ? argStr(payload.arguments, name) : argStr(source$1, name);
126
+ let subVal = Stdlib_Option.getOr(tmp, "");
127
+ filtered = items$2.filter(it => Stdlib_Option.getOr(argStr(it, match$2), "") === subVal);
128
+ } else {
129
+ filtered = items$2;
130
+ }
131
+ if (Stdlib_Option.getOr(payload.multi, false)) {
132
+ return filtered;
133
+ }
134
+ let item$1 = filtered[0];
135
+ if (item$1 !== undefined) {
136
+ return item$1;
137
+ } else {
138
+ return null;
139
+ }
96
140
  default:
97
141
  log.error("PgQueryResolver_Lambda", undefined, `unmapped resolver kind '` + other + `' for ` + rm);
98
142
  return Stdlib_JsError.throwWithMessage(`PgQueryResolver: unmapped resolver kind '` + other + `' for ` + rm);
99
143
  }
100
144
  } else {
101
- let match$1 = payload.kind;
102
- switch (match$1) {
145
+ let isMulti = Stdlib_Option.getOr(payload.multi, false);
146
+ let match$3 = payload.kind;
147
+ switch (match$3) {
103
148
  case "getById" :
104
149
  return null;
150
+ case "items" :
105
151
  case "list" :
106
152
  return emptyConnection();
153
+ case "resolveOne" :
154
+ if (isMulti) {
155
+ return [];
156
+ } else {
157
+ return null;
158
+ }
107
159
  default:
108
160
  return [];
109
161
  }
@@ -116,12 +168,68 @@ function register(readModelName, binding) {
116
168
  bindings[readModelName] = binding;
117
169
  }
118
170
 
171
+ let nodeTypeMap = {};
172
+
173
+ function registerNodeType(typeName, readModelName) {
174
+ nodeTypeMap[typeName] = readModelName;
175
+ }
176
+
177
+ async function handleNode(payload) {
178
+ let globalId = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
179
+ let decoded;
180
+ try {
181
+ decoded = atob(globalId);
182
+ } catch (exn) {
183
+ decoded = "";
184
+ }
185
+ let colonIdx = decoded.indexOf(":");
186
+ if (colonIdx <= 0) {
187
+ return null;
188
+ }
189
+ let typeName = decoded.slice(0, colonIdx);
190
+ let localId = decoded.slice(colonIdx + 1 | 0, decoded.length);
191
+ let binding = Stdlib_Option.flatMap(nodeTypeMap[typeName], rm => bindings[rm]);
192
+ if (binding === undefined) {
193
+ return null;
194
+ }
195
+ let match = await runInterceptor(binding, payload);
196
+ if (typeof match === "object") {
197
+ return null;
198
+ }
199
+ let items = await binding.ops.load(localId);
200
+ if (items.TAG !== "Ok") {
201
+ return null;
202
+ }
203
+ let item = items._0[0];
204
+ if (item === undefined) {
205
+ return null;
206
+ }
207
+ let obj = Object.assign({}, Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {}));
208
+ obj["__typename"] = typeName;
209
+ obj["id"] = globalId;
210
+ return obj;
211
+ }
212
+
119
213
  async function handler(payload, _context) {
120
- let binding = bindings[payload.readModelName];
214
+ let match = payload.kind;
215
+ let bindingKey;
216
+ switch (match) {
217
+ case "resolveMany" :
218
+ case "resolveOne" :
219
+ bindingKey = Stdlib_Option.getOr(payload.target, payload.readModelName);
220
+ break;
221
+ default:
222
+ bindingKey = payload.readModelName;
223
+ }
224
+ let match$1 = payload.kind;
225
+ if (match$1 === "node") {
226
+ return await handleNode(payload);
227
+ }
228
+ let binding = bindings[bindingKey];
121
229
  if (binding !== undefined) {
122
230
  return await dispatch(binding, payload);
123
231
  } else {
124
- return Stdlib_JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " + payload.readModelName);
232
+ return Stdlib_JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " + bindingKey);
125
233
  }
126
234
  }
127
235
 
@@ -136,6 +244,9 @@ export {
136
244
  dispatch,
137
245
  bindings,
138
246
  register,
247
+ nodeTypeMap,
248
+ registerNodeType,
249
+ handleNode,
139
250
  handler,
140
251
  }
141
252
  /* log Not a pure module */
@@ -1,10 +1,11 @@
1
1
  module AppSync = QueryDbResolvers_AppSync
2
2
  module NoOp = QueryDbResolvers_NoOp
3
+ module Lambda = QueryDbResolvers_Lambda
3
4
 
4
- // B3.1: Postgres-backed read models have no DynamoDB data source — suppress the
5
- // direct AppSync resolvers (their GraphQL fields stay unresolved until B3.2's
6
- // Lambda data source). DynamoDB-backed (incl. admin-exempt) read models keep the
7
- // full resolver set.
5
+ // B3.2b: Postgres-backed read models have no DynamoDB data source — their
6
+ // GraphQL Query fields are served by the shared PgQueryResolver Lambda data
7
+ // source (`QueryDbResolvers_Lambda`, Invoke templates PgQueryResolver_Lambda).
8
+ // DynamoDB-backed (incl. admin-exempt) read models keep the direct resolver set.
8
9
  module Selectable = {
9
10
  type api = QueryDbResolvers_AppSync_NoOp.api
10
11
  type role = QueryDbResolvers_AppSync_NoOp.role
@@ -21,7 +22,7 @@ module Selectable = {
21
22
  ~opts,
22
23
  ) =>
23
24
  if QueryDbBackend.isPostgresFor(name) {
24
- QueryDbResolvers_AppSync_NoOp.make(
25
+ QueryDbResolvers_Lambda.make(
25
26
  ~name,
26
27
  ~api,
27
28
  ~apiRole,
@@ -1,12 +1,12 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as QueryDbBackend$ReventlessAws from "./QueryDbBackend.res.mjs";
4
+ import * as QueryDbResolvers_Lambda$ReventlessAws from "./QueryDbResolvers_Lambda.res.mjs";
4
5
  import * as QueryDbResolvers_AppSync$ReventlessAws from "./QueryDbResolvers_AppSync.res.mjs";
5
- import * as QueryDbResolvers_AppSync_NoOp$ReventlessAws from "./QueryDbResolvers_AppSync_NoOp.res.mjs";
6
6
 
7
7
  function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolverConfigs, idsResolverConfigs, authorization, opts) {
8
8
  if (QueryDbBackend$ReventlessAws.isPostgresFor(name)) {
9
- return QueryDbResolvers_AppSync_NoOp$ReventlessAws.make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolverConfigs, idsResolverConfigs, authorization, opts);
9
+ return QueryDbResolvers_Lambda$ReventlessAws.make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolverConfigs, idsResolverConfigs, authorization, opts);
10
10
  } else {
11
11
  return QueryDbResolvers_AppSync$ReventlessAws.make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolverConfigs, idsResolverConfigs, authorization, opts);
12
12
  }
@@ -20,9 +20,12 @@ let AppSync;
20
20
 
21
21
  let NoOp;
22
22
 
23
+ let Lambda;
24
+
23
25
  export {
24
26
  AppSync,
25
27
  NoOp,
28
+ Lambda,
26
29
  Selectable,
27
30
  }
28
- /* QueryDbResolvers_AppSync-ReventlessAws Not a pure module */
31
+ /* QueryDbResolvers_Lambda-ReventlessAws Not a pure module */
@@ -0,0 +1,264 @@
1
+ // AppSync resolvers for Postgres-backed read models (B3.2b) — the Lambda-data-
2
+ // source parallel to QueryDbResolvers_AppSync's direct DynamoDB resolvers.
3
+ //
4
+ // Every Query field becomes a thin APPSYNC_JS unit resolver whose `Invoke`
5
+ // template carries { readModelName, kind, index?, arguments, identity } to the
6
+ // ONE shared PgQueryResolver Lambda (PgQueryResolver_Builder), which dispatches
7
+ // via PgQueryResolver_Lambda.dispatch. `dataSourceName` is the shared data
8
+ // source's (deferred) name, the same for every Postgres read model.
9
+ //
10
+ // Field names / includeIdParam / connectionSpec come from the same registry the
11
+ // AppSync path reads, so the SDL emitted by GraphQL_FragmentGenerator stays in
12
+ // lockstep. Kinds covered: getById, list (connection), index, byIds. Deferred to
13
+ // B3.2c: items (sub-id connection), @resolves/@resolvesMany, node, auth-table.
14
+
15
+ module Resolver = AppSync_Resolver_Retrying
16
+ open Reventless.ReadModel
17
+
18
+ type api = Types.AppSync.api
19
+ type role = Types.AppSync.role
20
+
21
+ // Identity block shared by every Invoke template (Cognito vs IAM), matching the
22
+ // shape PgQueryResolver_Lambda decodes (Reventless.Identity.t).
23
+ let identityBlock = `id != null && id.sub != null
24
+ ? { userId: id.sub, username: id.username, groups: id.claims?.['cognito:groups'] ?? [], claims: id.claims, provider: 'Cognito' }
25
+ : id != null
26
+ ? { userArn: id.userArn ?? null, accountId: id.accountId ?? null, username: id.username ?? null, provider: 'IAM' }
27
+ : null`
28
+
29
+ let invokeTemplate = (~readModelName: string, ~kind: string, ~index: option<string>=?) => {
30
+ let indexFrag = switch index {
31
+ | Some(ix) => `\n index: '${ix}',`
32
+ | None => ""
33
+ }
34
+ `import { util } from '@aws-appsync/utils';
35
+ export function request(ctx) {
36
+ const id = ctx.identity;
37
+ return {
38
+ operation: 'Invoke',
39
+ payload: {
40
+ readModelName: '${readModelName}',
41
+ kind: '${kind}',${indexFrag}
42
+ arguments: ctx.args,
43
+ identity: ${identityBlock}
44
+ }
45
+ };
46
+ }
47
+ export function response(ctx) {
48
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
49
+ return ctx.result;
50
+ }
51
+ `->Pulumi.Input.make
52
+ }
53
+
54
+ // Cross-table field resolver (@resolves/@resolvesMany, B3.2c): on a parent
55
+ // entity type's field, carrying the parent object (ctx.source) + baked target
56
+ // metadata (`extra`). `sourceType` is the parent read model (used only for auth
57
+ // scoping in dispatch); `target` is the target read model's binding key.
58
+ let invokeFieldTemplate = (~sourceType: string, ~kind: string, ~target: string, ~extra: string) =>
59
+ `import { util } from '@aws-appsync/utils';
60
+ export function request(ctx) {
61
+ const id = ctx.identity;
62
+ return {
63
+ operation: 'Invoke',
64
+ payload: {
65
+ readModelName: '${sourceType}',
66
+ kind: '${kind}',
67
+ target: '${target}',
68
+ source: ctx.source,
69
+ arguments: ctx.args,${extra}
70
+ identity: ${identityBlock}
71
+ }
72
+ };
73
+ }
74
+ export function response(ctx) {
75
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
76
+ return ctx.result;
77
+ }
78
+ `->Pulumi.Input.make
79
+
80
+ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
81
+ ~name: string,
82
+ ~api: api,
83
+ ~apiRole as _: role,
84
+ ~dataSourceName,
85
+ ~indexes: array<indexConfig>,
86
+ ~subIdField,
87
+ ~idResolverConfigs: array<idResolverConfig>,
88
+ ~idsResolverConfigs: array<idsResolverConfig>,
89
+ ~authorization as _: Reventless.Authorization.permission,
90
+ ~opts,
91
+ ) => {
92
+ let dataSourceName = dataSourceName->Pulumi.Output.asInput
93
+ let name = name->String.capitalize
94
+ let registryEntry = ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry->Dict.get(name)
95
+
96
+ let fieldNameForSingle = switch registryEntry {
97
+ | Some({singleFieldName}) => singleFieldName
98
+ | None => name->Resolver.Functions.uncapitalize
99
+ }
100
+ let includeIdParam = switch registryEntry {
101
+ | Some({includeIdParam}) => includeIdParam
102
+ | None => true
103
+ }
104
+ let connectionSpec = switch registryEntry {
105
+ | Some({connectionSpec}) => connectionSpec
106
+ | None => true
107
+ }
108
+ let fieldNameForAll = switch registryEntry {
109
+ | Some({listFieldName}) => listFieldName
110
+ | None => name ++ "s"
111
+ }
112
+ let labelField = switch registryEntry {
113
+ | Some({labelField: ?lf}) => lf->Option.getOr("id")
114
+ | None => "id"
115
+ }
116
+ let returnTypeName = switch registryEntry {
117
+ | Some({returnTypeName: rt}) => rt
118
+ | None => name
119
+ }
120
+
121
+ // Register the binding info the shared Lambda's env config needs (the entry
122
+ // point gets indexes/subIdField/schema from the spec module; labelField and
123
+ // includeIdParam come from this deploy-time registry). Keyed by spec name.
124
+ PgQueryResolver_Builder.register({readModelName: name, labelField, includeIdParam})
125
+
126
+ // Relay node type → this read model (for the shared node(id) resolver, B3.2c).
127
+ // Only entities addressable by id participate in node resolution.
128
+ if includeIdParam {
129
+ PgQueryResolver_Builder.registerNodeType(~typeName=returnTypeName, ~readModelName=name)
130
+ }
131
+
132
+ let mkResolver = (~resolverName, ~field, ~kind, ~index=?) =>
133
+ Resolver.makeUnitJsResolver(
134
+ ~name=resolverName,
135
+ ~api,
136
+ ~dataSourceName,
137
+ ~type_="Query"->Pulumi.Input.make,
138
+ ~field=field->Pulumi.Input.make,
139
+ ~code=invokeTemplate(~readModelName=name, ~kind, ~index?),
140
+ ~opts,
141
+ )
142
+
143
+ let stripLeadingBy = s =>
144
+ if s->String.startsWith("by") && s->String.length > 2 {
145
+ s->String.slice(~start=2, ~end=s->String.length)
146
+ } else {
147
+ s
148
+ }
149
+
150
+ // Resolvers are deferred into resourcesMaker (created inside the schema-pushed
151
+ // builderOutputs.apply, like the AppSync path) — so they only exist after the
152
+ // fields are ACTIVE and after PgQueryResolver_Builder.provision has resolved
153
+ // the shared data source name.
154
+ let resourcesMaker: ReventlessCore.QueryDb.resolversResourcesMaker = _allQueryDbs => {
155
+ // getById (or listAll when the read model has no id param, e.g. singletons).
156
+ let byId = mkResolver(
157
+ ~resolverName=fieldNameForSingle->String.capitalize,
158
+ ~field=fieldNameForSingle,
159
+ ~kind=includeIdParam ? "getById" : "list",
160
+ )
161
+
162
+ // Main list — Relay connection (or legacy list when connectionSpec=false).
163
+ let all = mkResolver(
164
+ ~resolverName=fieldNameForAll->String.capitalize,
165
+ ~field=fieldNameForAll,
166
+ ~kind=connectionSpec ? "list" : "list",
167
+ )
168
+
169
+ // Sub-id connection — {single}Items(id, filter, …); only when subId configured.
170
+ let items = switch subIdField {
171
+ | Some(_) => [
172
+ mkResolver(
173
+ ~resolverName=fieldNameForSingle->String.capitalize ++ "Items",
174
+ ~field=fieldNameForSingle ++ "Items",
175
+ ~kind="items",
176
+ ),
177
+ ]
178
+ | None => []
179
+ }
180
+
181
+ // Batched-by-ids — single-key projections only (mirrors the SDL).
182
+ let byIds = if includeIdParam && subIdField === None {
183
+ let byIdsField = fieldNameForAll ++ "ByIds"
184
+ [
185
+ mkResolver(
186
+ ~resolverName=byIdsField->String.capitalize,
187
+ ~field=byIdsField,
188
+ ~kind="byIds",
189
+ ),
190
+ ]
191
+ } else {
192
+ []
193
+ }
194
+
195
+ // Per-index equality queries: {single}By{Index}.
196
+ let byIndex = indexes->Array.map(({index}) => {
197
+ let resolverName =
198
+ fieldNameForSingle->String.capitalize ++ "By" ++ index->stripLeadingBy->String.capitalize
199
+ let field = fieldNameForSingle ++ "By" ++ index->stripLeadingBy->String.capitalize
200
+ mkResolver(~resolverName, ~field, ~kind="index", ~index)
201
+ })
202
+
203
+ // @resolves — single cross-table field resolver on this (parent) type. The
204
+ // target's binding key is its capitalized spec name (mirrors how the binding
205
+ // registry is keyed); the parent object flows via ctx.source.
206
+ let idResolvers = idResolverConfigs->Array.map(config => {
207
+ let {source: {idField, subId, resolvedField}, target} = config
208
+ let targetKey = target.tableName->String.capitalize
209
+ let (field, multi) = switch resolvedField {
210
+ | Single(f) => (f, false)
211
+ | Multi(f) => (f, true)
212
+ }
213
+ let targetFrag = switch target.idField {
214
+ | Index(ix) => `\n targetIndex: '${ix}',\n targetIndexIdField: '${ix}',`
215
+ | IndexWithId(ix, idf) => `\n targetIndex: '${ix}',\n targetIndexIdField: '${idf}',`
216
+ | Id => ""
217
+ }
218
+ let subIdFrag = switch subId {
219
+ | Field(f) => `\n sourceSubId: { kind: 'field', name: '${f}' },`
220
+ | Argument(a) => `\n sourceSubId: { kind: 'arg', name: '${a}' },`
221
+ | NoSubId => ""
222
+ }
223
+ let extra =
224
+ `\n sourceIdField: '${idField}',\n multi: ${multi ? "true" : "false"},` ++
225
+ targetFrag ++
226
+ subIdFrag
227
+ Resolver.makeUnitJsResolver(
228
+ ~name=name ++ field->String.capitalize,
229
+ ~api,
230
+ ~dataSourceName,
231
+ ~type_=name->Pulumi.Input.make,
232
+ ~field=field->Pulumi.Input.make,
233
+ ~code=invokeFieldTemplate(~sourceType=name, ~kind="resolveOne", ~target=targetKey, ~extra),
234
+ ~opts,
235
+ )
236
+ })
237
+
238
+ // @resolvesMany — batch cross-table field resolver (BatchGet by ids).
239
+ let idsResolvers = idsResolverConfigs->Array.map(config => {
240
+ let {source: {idsField, resolvedField}, target} = config
241
+ let targetKey = target.tableName->String.capitalize
242
+ let extra = `\n sourceIdsField: '${idsField}',`
243
+ Resolver.makeUnitJsResolver(
244
+ ~name=name ++ resolvedField->String.capitalize,
245
+ ~api,
246
+ ~dataSourceName,
247
+ ~type_=name->Pulumi.Input.make,
248
+ ~field=resolvedField->Pulumi.Input.make,
249
+ ~code=invokeFieldTemplate(~sourceType=name, ~kind="resolveMany", ~target=targetKey, ~extra),
250
+ ~opts,
251
+ )
252
+ })
253
+
254
+ [byId, all]
255
+ ->Array.concat(items)
256
+ ->Array.concat(byIds)
257
+ ->Array.concat(byIndex)
258
+ ->Array.concat(idResolvers)
259
+ ->Array.concat(idsResolvers)
260
+ ->Array.map(Util.AppSync.toResourceNative)
261
+ }
262
+
263
+ {ReventlessCore.QueryDb_Adapter.resources: [], resourcesMaker}
264
+ }