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

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docker-compose.postgres.yml +21 -0
  3. package/package.json +7 -6
  4. package/scripts/run-pg-integration-tests.sh +58 -0
  5. package/src/Platform.res +49 -0
  6. package/src/Platform.res.mjs +48 -2
  7. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
  8. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
  9. package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
  10. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
  11. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
  12. package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
  13. package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
  14. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
  15. package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
  16. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
  17. package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
  18. package/src/adapter/Postgres/PgConnection.res +70 -10
  19. package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
  20. package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
  21. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
  22. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +279 -0
  23. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +170 -0
  24. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +202 -11
  25. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +150 -9
  26. package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
  27. package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
  28. package/src/adapter/QueryDb/QueryDbResolvers.res +6 -5
  29. package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +6 -3
  30. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +288 -0
  31. package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +170 -0
  32. package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
  33. package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
  34. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +5 -1
  35. package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +3 -3
  36. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
  37. package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
  38. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
  39. package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
  40. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
  41. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
  42. package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
  43. package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
  44. package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +88 -0
  45. package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
  46. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
  47. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
  48. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
  49. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
  50. package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
  51. package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
  52. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
  53. package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
  54. package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
  55. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
  56. package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
  57. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
  58. package/src/util/Util_Bundle.res +71 -1
  59. package/src/util/Util_Bundle.res.mjs +49 -1
  60. package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
  61. package/tests/PgConnectionJsonTest.res +84 -0
  62. package/tests/PgConnectionJsonTest.res.mjs +68 -0
  63. package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
  64. package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
  65. package/tests/PgPipeline_IntegrationTest.res +3 -1
  66. package/tests/PgQueryResolver_LambdaTest.res +163 -2
  67. package/tests/PgQueryResolver_LambdaTest.res.mjs +226 -14
  68. package/tests/StateTopicPublishTest.res +162 -0
  69. package/tests/StateTopicPublishTest.res.mjs +158 -0
  70. package/tests/Util_BundleEsmLoaderTest.res +57 -0
  71. package/tests/Util_BundleEsmLoaderTest.res.mjs +44 -0
@@ -0,0 +1,170 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Aws from "@pulumi/aws";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
6
+ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
8
+ import * as Pulumi from "@pulumi/pulumi";
9
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
10
+ import * as AWS$ReventlessAws from "../AWS.res.mjs";
11
+ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
12
+ import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
13
+ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
14
+ import * as PgConnection$ReventlessAws from "../Postgres/PgConnection.res.mjs";
15
+ import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
16
+ import * as AppSync_Resolver_Retrying$ReventlessAws from "../Api/AppSync_Resolver_Retrying.res.mjs";
17
+ import * as RuntimeEnvironment_Lambda$ReventlessAws from "../Runtime/RuntimeEnvironment_Lambda.res.mjs";
18
+ import * as EventCollectorRuntime_Builder_Single$ReventlessAws from "../Runtime/EventCollectorRuntime_Builder_Single.res.mjs";
19
+
20
+ let log = Logger$ReventlessCore.fromEnv();
21
+
22
+ let resolveDataSourceName = {
23
+ contents: param => {}
24
+ };
25
+
26
+ let dataSourceNamePromise = new Promise((resolve, _reject) => {
27
+ resolveDataSourceName.contents = resolve;
28
+ });
29
+
30
+ let entries = {};
31
+
32
+ function register(entry) {
33
+ entries[entry.readModelName] = entry;
34
+ }
35
+
36
+ let nodeTypes = {};
37
+
38
+ function registerNodeType(typeName, readModelName) {
39
+ nodeTypes[typeName] = readModelName;
40
+ }
41
+
42
+ function bool(b) {
43
+ if (b) {
44
+ return "true";
45
+ } else {
46
+ return "false";
47
+ }
48
+ }
49
+
50
+ let nodeResolverCode = `import { util } from '@aws-appsync/utils';
51
+ export function request(ctx) {
52
+ const id = ctx.identity;
53
+ return {
54
+ operation: 'Invoke',
55
+ payload: {
56
+ readModelName: '',
57
+ kind: 'node',
58
+ arguments: ctx.args,
59
+ identity: id != null && id.sub != null
60
+ ? { userId: id.sub, username: id.username, groups: id.claims?.['cognito:groups'] ?? [], claims: id.claims, provider: 'Cognito' }
61
+ : id != null
62
+ ? { userArn: id.userArn ?? null, accountId: id.accountId ?? null, username: id.username ?? null, provider: 'IAM' }
63
+ : null
64
+ }
65
+ };
66
+ }
67
+ export function response(ctx) {
68
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
69
+ return ctx.result;
70
+ }
71
+ `;
72
+
73
+ function pgConnectionJson(cc) {
74
+ return JSON.stringify(PgConnection$ReventlessAws.connectionConfigToJson(undefined, cc));
75
+ }
76
+
77
+ function provision(api, selection, opts, createNodeResolverOpt) {
78
+ let createNodeResolver = createNodeResolverOpt !== undefined ? createNodeResolverOpt : true;
79
+ let handlers = Stdlib_Array.filterMap(Object.values(entries), entry => {
80
+ let info = EventCollectorRuntime_Builder_Single$ReventlessAws.readModelInfos[entry.readModelName];
81
+ if (info !== undefined && info.pgBacked) {
82
+ return [
83
+ entry,
84
+ info.specModulePath
85
+ ];
86
+ }
87
+ });
88
+ if (handlers.length === 0) {
89
+ return;
90
+ }
91
+ let customOpts = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
92
+ let name = "PgQueryResolver";
93
+ let packageDirs = {};
94
+ handlers.forEach(param => {
95
+ let pkg = Util_Bundle$ReventlessAws.extractPackageName(param[1]);
96
+ packageDirs[pkg] = Util_Bundle$ReventlessAws.resolvePackageRoot(pkg);
97
+ });
98
+ let handlerJsons = handlers.toSorted((param, param$1) => Primitive_string.compare(param[0].readModelName, param$1[0].readModelName)).map(param => {
99
+ let entry = param[0];
100
+ let specModule = Stdlib_Option.getOr(JSON.stringify(param[1]), `""`);
101
+ return `{"readModelName":"` + entry.readModelName + `","specModule":` + specModule + `,"labelField":"` + entry.labelField + `","includeIdParam":` + (
102
+ entry.includeIdParam ? "true" : "false"
103
+ ) + `}`;
104
+ });
105
+ let nodeTypesJson = Object.entries(nodeTypes).toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0])).map(param => `"` + param[0] + `":"` + param[1] + `"`).join(",");
106
+ let queryResolverConfig = selection.connectionConfig.apply(cc => `{"pgConnection":` + pgConnectionJson(cc) + `,"handlers":[` + handlerJsons.join(",") + `],"nodeTypes":{` + nodeTypesJson + `}}`);
107
+ let envVars = {};
108
+ envVars["QUERY_RESOLVER_CONFIG"] = queryResolverConfig;
109
+ envVars["Environment"] = Pulumi.getStack();
110
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs", packageDirs, undefined);
111
+ let vpcConfig = selection.securityGroupId.apply(sgId => ({
112
+ subnetIds: selection.subnetIds,
113
+ securityGroupIds: [sgId]
114
+ }));
115
+ let runtime = RuntimeEnvironment_Lambda$ReventlessAws.makeFromCodeAsset(name, match.code, match.sourceCodeHash, envVars, 512, 30, undefined, undefined, undefined, undefined, vpcConfig, opts);
116
+ selection.connectionConfig.apply(cc => new (Aws.iam.RolePolicy)(name + "-pgSecret", {
117
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "-pgSecretPolicy", [{
118
+ Sid: "AllowGetPgSecret",
119
+ Effect: "Allow",
120
+ Action: "secretsmanager:GetSecretValue",
121
+ Resource: cc.secretArn
122
+ }])),
123
+ role: runtime.parts.lambdaRole.id
124
+ }));
125
+ let dataSourceRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "DataSource", Pulumi.output(AWS$ReventlessAws.AppSync.principal), customOpts);
126
+ let lambdaArn = Output$Pulumi.flatMap(runtime.parts.lambda, l => l.arn);
127
+ Pulumi.all([
128
+ lambdaArn,
129
+ dataSourceRole.id
130
+ ]).apply(param => new (Aws.iam.RolePolicy)(name + "DataSourceInvoke", {
131
+ policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "DataSourceInvokePolicy", [{
132
+ Sid: "AllowDataSourceInvokeLambda",
133
+ Effect: "Allow",
134
+ Action: "lambda:InvokeFunction",
135
+ Resource: param[0]
136
+ }])),
137
+ role: param[1]
138
+ }));
139
+ let dataSource = new (Aws.appsync.DataSource)(name + "DataSource", {
140
+ type: "AWS_LAMBDA",
141
+ apiId: Output$Pulumi.flatMap(api, a => a.id),
142
+ lambdaConfig: {
143
+ functionArn: lambdaArn
144
+ },
145
+ serviceRoleArn: dataSourceRole.arn
146
+ }, customOpts);
147
+ if (createNodeResolver && Object.entries(nodeTypes).length !== 0) {
148
+ AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name + "NodeResolver", api, dataSource.name, "Query", "node", nodeResolverCode, customOpts);
149
+ }
150
+ dataSource.name.apply(n => resolveDataSourceName.contents(n));
151
+ log.info("PgQueryResolver_Builder", undefined, `provisioned for ` + handlers.length.toString() + ` read model(s)`);
152
+ }
153
+
154
+ let dataSourceName = dataSourceNamePromise;
155
+
156
+ export {
157
+ log,
158
+ resolveDataSourceName,
159
+ dataSourceNamePromise,
160
+ dataSourceName,
161
+ entries,
162
+ register,
163
+ nodeTypes,
164
+ registerNodeType,
165
+ bool,
166
+ nodeResolverCode,
167
+ pgConnectionJson,
168
+ provision,
169
+ }
170
+ /* log Not a pure module */
@@ -19,16 +19,44 @@
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,
44
+ // Auth-table pipeline (B3.2c): a group-restricted index. Group members must
45
+ // own the resource (the auth table maps index value → {<group>Id: username});
46
+ // non-members pass through (mirrors the DynamoDB pipeline's earlyReturn).
47
+ authTable?: string,
48
+ authGroup?: string,
28
49
  arguments: JSON.t,
29
50
  identity: Reventless.Identity.t,
30
51
  }
31
52
 
53
+ // "Owner" → "ownerId" (auth-table owner field name, matching authorizeIndexedAccess).
54
+ let authIdField = (group: string): string =>
55
+ switch group->String.get(0) {
56
+ | Some(c) => c->String.toLowerCase ++ group->String.slice(~start=1, ~end=group->String.length) ++ "Id"
57
+ | None => "Id"
58
+ }
59
+
32
60
  // The Postgres query push-downs a binding needs (QueryEnginePostgres.Make
33
61
  // provides these; the mock in tests provides in-memory equivalents).
34
62
  type pushdowns = {
@@ -40,6 +68,13 @@ type pushdowns = {
40
68
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
41
69
  ~labelField: string,
42
70
  ) => promise<option<JSON.t>>,
71
+ // Sub-id connection ({single}Items) — keyset over sub_key within a partition.
72
+ itemsPage: (
73
+ ~readModelName: string,
74
+ ~subIdField: string,
75
+ ~id: string,
76
+ ~argsDict: dict<JSON.t>,
77
+ ) => promise<JSON.t>,
43
78
  // Full materialisation for the list fallback (shapes listPage declines).
44
79
  scanAll: (~readModelName: string) => promise<array<JSON.t>>,
45
80
  }
@@ -107,14 +142,23 @@ let runInterceptor = async (~binding, ~payload): ReventlessCore.QueryDb_Callback
107
142
  }
108
143
  }
109
144
 
110
- let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
145
+ // `lookupBinding` resolves another read model's binding by key needed only for
146
+ // the auth-table pipeline (the auth table is another read model, loaded by the
147
+ // index value). The handler passes the module-level registry; tests pass a mock.
148
+ let dispatch = async (
149
+ ~binding: binding,
150
+ ~lookupBinding: string => option<binding>=_ => None,
151
+ ~payload: payload,
152
+ ): JSON.t => {
111
153
  let rm = payload.readModelName
112
154
  switch await runInterceptor(~binding, ~payload) {
113
155
  | Deny(_) =>
114
- // Empty shape per kind (null for single, [] for lists, empty connection).
156
+ // Empty shape per kind (null for single, connection for list/items, [] else).
157
+ let isMulti = payload.multi->Option.getOr(false)
115
158
  switch payload.kind {
116
159
  | "getById" => JSON.Encode.null
117
- | "list" => emptyConnection()
160
+ | "resolveOne" if !isMulti => JSON.Encode.null
161
+ | "list" | "items" => emptyConnection()
118
162
  | _ => JSON.Encode.array([])
119
163
  }
120
164
  | Allow =>
@@ -134,6 +178,21 @@ let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
134
178
  let ids = payload.arguments->argStrs("ids")
135
179
  JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=rm, ids))
136
180
 
181
+ | "items" =>
182
+ // Sub-id connection: {single}Items(id, filter, first/after/last/before).
183
+ // Requires a subIdField; without one it's an empty connection.
184
+ switch binding.subIdField {
185
+ | Some(subIdField) =>
186
+ let id = payload.arguments->argStr("id")->Option.getOr("")
187
+ await binding.pushdowns.itemsPage(
188
+ ~readModelName=rm,
189
+ ~subIdField,
190
+ ~id,
191
+ ~argsDict=payload.arguments->argObj,
192
+ )
193
+ | None => emptyConnection()
194
+ }
195
+
137
196
  | "index" =>
138
197
  // The index arg name is the index; its stored field is idField ?? index.
139
198
  let indexName = payload.index->Option.getOr("")
@@ -143,7 +202,35 @@ let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
143
202
  ->Option.flatMap(ic => ic.idField)
144
203
  ->Option.getOr(indexName)
145
204
  let value = payload.arguments->argStr(indexName)->Option.getOr("")
146
- JSON.Encode.array(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))
205
+ // Auth-table pipeline: a group-restricted index. Non-members pass through;
206
+ // members must own the resource (auth table maps index value →
207
+ // {<group>Id: username}). Auth table is another read model, loaded via the
208
+ // registry (unsupported → deny if it isn't Postgres-registered).
209
+ let authorized = switch (payload.authGroup, payload.authTable) {
210
+ | (Some(group), Some(authTable)) =>
211
+ if !Reventless.Identity.hasGroup(payload.identity, group) {
212
+ true
213
+ } else {
214
+ switch lookupBinding(authTable) {
215
+ | Some(authBinding) =>
216
+ switch await authBinding.ops.load(value) {
217
+ | Ok(items) =>
218
+ items
219
+ ->Array.get(0)
220
+ ->Option.flatMap(row => row->argStr(authIdField(group)))
221
+ ->Option.mapOr(false, owner => owner == payload.identity.username)
222
+ | Error(_) => false
223
+ }
224
+ | None => false
225
+ }
226
+ }
227
+ | _ => true
228
+ }
229
+ if authorized {
230
+ JSON.Encode.array(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))
231
+ } else {
232
+ JSON.Encode.array([])
233
+ }
147
234
 
148
235
  | "list" =>
149
236
  let argsDict = payload.arguments->argObj
@@ -169,6 +256,55 @@ let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
169
256
  )
170
257
  }
171
258
 
259
+ // Cross-table single-ID field resolver (@resolves). `binding` is the TARGET
260
+ // binding (the handler looks it up by payload.target); the key comes from the
261
+ // parent object (payload.source[sourceIdField]).
262
+ | "resolveOne" =>
263
+ let target = payload.target->Option.getOr(rm)
264
+ let source = payload.source->Option.getOr(JSON.Encode.null)
265
+ let key = source->argStr(payload.sourceIdField->Option.getOr(""))->Option.getOr("")
266
+ let items = switch payload.targetIndex {
267
+ | Some(ix) =>
268
+ await binding.pushdowns.indexLookup(
269
+ ~readModelName=target,
270
+ payload.targetIndexIdField->Option.getOr(ix),
271
+ key,
272
+ )
273
+ | None =>
274
+ switch await binding.ops.load(key) {
275
+ | Ok(items) => items
276
+ | Error(_) => []
277
+ }
278
+ }
279
+ // Optional target sort-key filter (source field or GraphQL arg).
280
+ let filtered = switch (payload.sourceSubId, binding.subIdField) {
281
+ | (Some({kind, name}), Some(subField)) =>
282
+ let subVal =
283
+ switch kind {
284
+ | "arg" => payload.arguments->argStr(name)
285
+ | _ => source->argStr(name)
286
+ }->Option.getOr("")
287
+ items->Array.filter(it => it->argStr(subField)->Option.getOr("") == subVal)
288
+ | _ => items
289
+ }
290
+ if payload.multi->Option.getOr(false) {
291
+ JSON.Encode.array(filtered)
292
+ } else {
293
+ switch filtered->Array.get(0) {
294
+ | Some(item) => item
295
+ | None => JSON.Encode.null
296
+ }
297
+ }
298
+
299
+ // Cross-table batch field resolver (@resolvesMany). ids come from the parent
300
+ // object (payload.source[sourceIdsField]); BatchGet the target by partition
301
+ // key (missing ids drop out).
302
+ | "resolveMany" =>
303
+ let target = payload.target->Option.getOr(rm)
304
+ let source = payload.source->Option.getOr(JSON.Encode.null)
305
+ let ids = source->argStrs(payload.sourceIdsField->Option.getOr(""))
306
+ JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=target, ids))
307
+
172
308
  | other =>
173
309
  // Fail loudly on unmapped kinds (feature-parity guard, per the B3.2 plan).
174
310
  log.error(~comp="PgQueryResolver_Lambda", `unmapped resolver kind '${other}' for ${rm}`)
@@ -182,11 +318,66 @@ let bindings: dict<binding> = Dict.make()
182
318
  let register = (~readModelName: string, binding: binding): unit =>
183
319
  bindings->Dict.set(readModelName, binding)
184
320
 
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
- )
321
+ // Relay node type read-model-name map (B3.2c), populated at Lambda init from
322
+ // the deploy-time registerNodeType calls.
323
+ let nodeTypeMap: dict<string> = Dict.make()
324
+ let registerNodeType = (~typeName: string, ~readModelName: string): unit =>
325
+ nodeTypeMap->Dict.set(typeName, readModelName)
326
+
327
+ // node(id: ID!) — decode the global id (base64 of `typeName:localId`, matching
328
+ // AppSync's nodeDecodeGlobalId), map typeName → read model, load by localId,
329
+ // return the item tagged with __typename and the original global id. Runs the
330
+ // target's authorization/interceptor. Self-contained (getById returns raw ids,
331
+ // so no encoding elsewhere is affected).
332
+ let handleNode = async (~payload: payload): JSON.t => {
333
+ let globalId = payload.arguments->argStr("id")->Option.getOr("")
334
+ let decoded = try atob(globalId) catch {
335
+ | _ => ""
192
336
  }
337
+ let colonIdx = decoded->String.indexOf(":")
338
+ if colonIdx <= 0 {
339
+ JSON.Encode.null
340
+ } else {
341
+ let typeName = decoded->String.slice(~start=0, ~end=colonIdx)
342
+ let localId = decoded->String.slice(~start=colonIdx + 1, ~end=decoded->String.length)
343
+ switch nodeTypeMap->Dict.get(typeName)->Option.flatMap(rm => bindings->Dict.get(rm)) {
344
+ | Some(binding) =>
345
+ switch await runInterceptor(~binding, ~payload) {
346
+ | Deny(_) => JSON.Encode.null
347
+ | Allow =>
348
+ switch await binding.ops.load(localId) {
349
+ | Ok(items) =>
350
+ switch items->Array.get(0) {
351
+ | Some(item) =>
352
+ let obj = item->JSON.Decode.object->Option.getOr(Dict.make())->Dict.copy
353
+ obj->Dict.set("__typename", JSON.Encode.string(typeName))
354
+ obj->Dict.set("id", JSON.Encode.string(globalId))
355
+ JSON.Encode.object(obj)
356
+ | None => JSON.Encode.null
357
+ }
358
+ | Error(_) => JSON.Encode.null
359
+ }
360
+ }
361
+ | None => JSON.Encode.null
362
+ }
363
+ }
364
+ }
365
+
366
+ let handler = async (payload: payload, _context) => {
367
+ // node decodes its own target; the cross-table field resolvers dispatch against
368
+ // the TARGET binding; everything else against the payload's own read model.
369
+ let bindingKey = switch payload.kind {
370
+ | "resolveOne" | "resolveMany" => payload.target->Option.getOr(payload.readModelName)
371
+ | _ => payload.readModelName
372
+ }
373
+ switch payload.kind {
374
+ | "node" => await handleNode(~payload)
375
+ | _ =>
376
+ switch bindings->Dict.get(bindingKey) {
377
+ | Some(binding) =>
378
+ await dispatch(~binding, ~lookupBinding=name => bindings->Dict.get(name), ~payload)
379
+ | None =>
380
+ JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " ++ bindingKey)
381
+ }
382
+ }
383
+ }
@@ -4,6 +4,7 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
+ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
7
8
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
9
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
9
10
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
@@ -11,6 +12,15 @@ import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core
11
12
 
12
13
  let log = Logger$ReventlessCore.fromEnv();
13
14
 
15
+ function authIdField(group) {
16
+ let c = group[0];
17
+ if (c !== undefined) {
18
+ return c.toLowerCase() + group.slice(1, group.length) + "Id";
19
+ } else {
20
+ return "Id";
21
+ }
22
+ }
23
+
14
24
  function argObj(args) {
15
25
  return Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
16
26
  }
@@ -55,7 +65,8 @@ async function runInterceptor(binding, payload) {
55
65
  }
56
66
  }
57
67
 
58
- async function dispatch(binding, payload) {
68
+ async function dispatch(binding, lookupBindingOpt, payload) {
69
+ let lookupBinding = lookupBindingOpt !== undefined ? lookupBindingOpt : param => {};
59
70
  let rm = payload.readModelName;
60
71
  let match = await runInterceptor(binding, payload);
61
72
  if (typeof match !== "object") {
@@ -84,26 +95,96 @@ async function dispatch(binding, payload) {
84
95
  let indexName = Stdlib_Option.getOr(payload.index, "");
85
96
  let field = Stdlib_Option.getOr(Stdlib_Option.flatMap(binding.indexes.find(ic => ic.index === indexName), ic => ic.idField), indexName);
86
97
  let value = Stdlib_Option.getOr(argStr(payload.arguments, indexName), "");
87
- return await binding.pushdowns.indexLookup(rm, field, value);
98
+ let match$1 = payload.authGroup;
99
+ let match$2 = payload.authTable;
100
+ let authorized;
101
+ if (match$1 !== undefined && match$2 !== undefined && Identity$Reventless.hasGroup(payload.identity, match$1)) {
102
+ let authBinding = lookupBinding(match$2);
103
+ if (authBinding !== undefined) {
104
+ let items$1 = await authBinding.ops.load(value);
105
+ authorized = items$1.TAG === "Ok" ? Stdlib_Option.mapOr(Stdlib_Option.flatMap(items$1._0[0], row => argStr(row, authIdField(match$1))), false, owner => owner === payload.identity.username) : false;
106
+ } else {
107
+ authorized = false;
108
+ }
109
+ } else {
110
+ authorized = true;
111
+ }
112
+ if (authorized) {
113
+ return await binding.pushdowns.indexLookup(rm, field, value);
114
+ } else {
115
+ return [];
116
+ }
117
+ case "items" :
118
+ let subIdField = binding.subIdField;
119
+ if (subIdField === undefined) {
120
+ return emptyConnection();
121
+ }
122
+ let id$1 = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
123
+ return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments));
88
124
  case "list" :
89
125
  let argsDict = argObj(payload.arguments);
90
126
  let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField);
91
127
  if (conn !== undefined) {
92
128
  return conn;
93
129
  }
94
- let items$1 = await binding.pushdowns.scanAll(rm);
95
- return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, param => {});
130
+ let items$2 = await binding.pushdowns.scanAll(rm);
131
+ return QueryDbListQuery$ReventlessCore.run(items$2, argsDict, binding.capability, binding.labelField, param => {});
132
+ case "resolveMany" :
133
+ let target = Stdlib_Option.getOr(payload.target, rm);
134
+ let source = Stdlib_Option.getOr(payload.source, null);
135
+ let ids$1 = argStrs(source, Stdlib_Option.getOr(payload.sourceIdsField, ""));
136
+ return await binding.pushdowns.byIds(target, ids$1);
137
+ case "resolveOne" :
138
+ let target$1 = Stdlib_Option.getOr(payload.target, rm);
139
+ let source$1 = Stdlib_Option.getOr(payload.source, null);
140
+ let key = Stdlib_Option.getOr(argStr(source$1, Stdlib_Option.getOr(payload.sourceIdField, "")), "");
141
+ let ix = payload.targetIndex;
142
+ let items$3;
143
+ if (ix !== undefined) {
144
+ items$3 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
145
+ } else {
146
+ let items$4 = await binding.ops.load(key);
147
+ items$3 = items$4.TAG === "Ok" ? items$4._0 : [];
148
+ }
149
+ let match$3 = payload.sourceSubId;
150
+ let match$4 = binding.subIdField;
151
+ let filtered;
152
+ if (match$3 !== undefined && match$4 !== undefined) {
153
+ let name = match$3.name;
154
+ let tmp = match$3.kind === "arg" ? argStr(payload.arguments, name) : argStr(source$1, name);
155
+ let subVal = Stdlib_Option.getOr(tmp, "");
156
+ filtered = items$3.filter(it => Stdlib_Option.getOr(argStr(it, match$4), "") === subVal);
157
+ } else {
158
+ filtered = items$3;
159
+ }
160
+ if (Stdlib_Option.getOr(payload.multi, false)) {
161
+ return filtered;
162
+ }
163
+ let item$1 = filtered[0];
164
+ if (item$1 !== undefined) {
165
+ return item$1;
166
+ } else {
167
+ return null;
168
+ }
96
169
  default:
97
170
  log.error("PgQueryResolver_Lambda", undefined, `unmapped resolver kind '` + other + `' for ` + rm);
98
171
  return Stdlib_JsError.throwWithMessage(`PgQueryResolver: unmapped resolver kind '` + other + `' for ` + rm);
99
172
  }
100
173
  } else {
101
- let match$1 = payload.kind;
102
- switch (match$1) {
174
+ let isMulti = Stdlib_Option.getOr(payload.multi, false);
175
+ let match$5 = payload.kind;
176
+ switch (match$5) {
103
177
  case "getById" :
104
178
  return null;
179
+ case "items" :
105
180
  case "list" :
106
181
  return emptyConnection();
182
+ case "resolveOne" :
183
+ if (isMulti) {
184
+ return [];
185
+ } else {
186
+ return null;
187
+ }
107
188
  default:
108
189
  return [];
109
190
  }
@@ -116,17 +197,74 @@ function register(readModelName, binding) {
116
197
  bindings[readModelName] = binding;
117
198
  }
118
199
 
200
+ let nodeTypeMap = {};
201
+
202
+ function registerNodeType(typeName, readModelName) {
203
+ nodeTypeMap[typeName] = readModelName;
204
+ }
205
+
206
+ async function handleNode(payload) {
207
+ let globalId = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
208
+ let decoded;
209
+ try {
210
+ decoded = atob(globalId);
211
+ } catch (exn) {
212
+ decoded = "";
213
+ }
214
+ let colonIdx = decoded.indexOf(":");
215
+ if (colonIdx <= 0) {
216
+ return null;
217
+ }
218
+ let typeName = decoded.slice(0, colonIdx);
219
+ let localId = decoded.slice(colonIdx + 1 | 0, decoded.length);
220
+ let binding = Stdlib_Option.flatMap(nodeTypeMap[typeName], rm => bindings[rm]);
221
+ if (binding === undefined) {
222
+ return null;
223
+ }
224
+ let match = await runInterceptor(binding, payload);
225
+ if (typeof match === "object") {
226
+ return null;
227
+ }
228
+ let items = await binding.ops.load(localId);
229
+ if (items.TAG !== "Ok") {
230
+ return null;
231
+ }
232
+ let item = items._0[0];
233
+ if (item === undefined) {
234
+ return null;
235
+ }
236
+ let obj = Object.assign({}, Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {}));
237
+ obj["__typename"] = typeName;
238
+ obj["id"] = globalId;
239
+ return obj;
240
+ }
241
+
119
242
  async function handler(payload, _context) {
120
- let binding = bindings[payload.readModelName];
243
+ let match = payload.kind;
244
+ let bindingKey;
245
+ switch (match) {
246
+ case "resolveMany" :
247
+ case "resolveOne" :
248
+ bindingKey = Stdlib_Option.getOr(payload.target, payload.readModelName);
249
+ break;
250
+ default:
251
+ bindingKey = payload.readModelName;
252
+ }
253
+ let match$1 = payload.kind;
254
+ if (match$1 === "node") {
255
+ return await handleNode(payload);
256
+ }
257
+ let binding = bindings[bindingKey];
121
258
  if (binding !== undefined) {
122
- return await dispatch(binding, payload);
259
+ return await dispatch(binding, name => bindings[name], payload);
123
260
  } else {
124
- return Stdlib_JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " + payload.readModelName);
261
+ return Stdlib_JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " + bindingKey);
125
262
  }
126
263
  }
127
264
 
128
265
  export {
129
266
  log,
267
+ authIdField,
130
268
  argObj,
131
269
  argStr,
132
270
  argStrs,
@@ -136,6 +274,9 @@ export {
136
274
  dispatch,
137
275
  bindings,
138
276
  register,
277
+ nodeTypeMap,
278
+ registerNodeType,
279
+ handleNode,
139
280
  handler,
140
281
  }
141
282
  /* log Not a pure module */
@@ -50,3 +50,11 @@ let isPostgresFor = (name: string): bool =>
50
50
  /** Whether ANY read model on this platform is Postgres-backed (used to decide
51
51
  whether the projection Lambdas need VPC/secret access). */
52
52
  let isPostgres = (): bool => selectionRef.contents->Option.isSome
53
+
54
+ // B3.3: Postgres read models routed through `QueryDbStorage.SelectableStream`
55
+ // (i.e. subscription-enabled). Lives here — a leaf both the storage selector and
56
+ // the projection-Lambda runtime builders already import — so the runtime builder
57
+ // can consult it without pulling in QueryDbStorage (which would close a build
58
+ // cycle via QueryDbStorage_Postgres → PgQueryResolver_Builder). Membership means
59
+ // "publish live updates from the projection Lambda after save/delete".
60
+ let postgresStreamRegistry: Set.t<string> = Set.make()