@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.
- package/CHANGELOG.md +23 -0
- package/docker-compose.postgres.yml +21 -0
- package/package.json +7 -6
- package/scripts/run-pg-integration-tests.sh +58 -0
- package/src/Platform.res +49 -0
- package/src/Platform.res.mjs +48 -2
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +7 -1
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +10 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +7 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +10 -1
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res +6 -21
- package/src/adapter/Counter/CounterHandler_DynamoDbStream.res.mjs +2 -17
- package/src/adapter/DcbEventLog/DcbBackend.res +4 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res +2 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres.res.mjs +7 -1
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +1 -11
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +2 -24
- package/src/adapter/Postgres/PgConnection.res +70 -10
- package/src/adapter/Postgres/PgConnection.res.mjs +48 -3
- package/src/adapter/Postgres/PgMigration_Builder.res +127 -0
- package/src/adapter/Postgres/PgMigration_Builder.res.mjs +51 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res +279 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +170 -0
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +202 -11
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +150 -9
- package/src/adapter/QueryDb/QueryDbBackend.res +8 -0
- package/src/adapter/QueryDb/QueryDbBackend.res.mjs +4 -1
- package/src/adapter/QueryDb/QueryDbResolvers.res +6 -5
- package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +6 -3
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +288 -0
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +170 -0
- package/src/adapter/QueryDb/QueryDbStorage.res +6 -1
- package/src/adapter/QueryDb/QueryDbStorage.res.mjs +2 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +5 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +3 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +3 -14
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +2 -25
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +7 -1
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +78 -15
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +43 -26
- package/src/adapter/Runtime/PgMigrationEntryPoint.mjs +40 -0
- package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +88 -0
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +17 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +10 -0
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +3 -0
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +9 -14
- package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +2 -25
- package/src/adapter/Runtime/StateTopicPublish.mjs +204 -0
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +17 -3
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +86 -17
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +50 -33
- package/src/adapter/StateTopic/StateTopic_AppSync.res +7 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +10 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res +7 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +10 -1
- package/src/util/Util_Bundle.res +71 -1
- package/src/util/Util_Bundle.res.mjs +49 -1
- package/tests/PgChangeFeedRelay_IntegrationTest.res +3 -3
- package/tests/PgConnectionJsonTest.res +84 -0
- package/tests/PgConnectionJsonTest.res.mjs +68 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res +82 -0
- package/tests/PgMigrationEntryPoint_IntegrationTest.res.mjs +63 -0
- package/tests/PgPipeline_IntegrationTest.res +3 -1
- package/tests/PgQueryResolver_LambdaTest.res +163 -2
- package/tests/PgQueryResolver_LambdaTest.res.mjs +226 -14
- package/tests/StateTopicPublishTest.res +162 -0
- package/tests/StateTopicPublishTest.res.mjs +158 -0
- package/tests/Util_BundleEsmLoaderTest.res +57 -0
- 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
|
-
|
|
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,
|
|
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
|
-
| "
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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$
|
|
95
|
-
return QueryDbListQuery$ReventlessCore.run(items$
|
|
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
|
|
102
|
-
|
|
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
|
|
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 " +
|
|
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()
|