@reventlessdev/reventless-aws 3.0.0-alpha.175 → 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.
- package/CHANGELOG.md +22 -0
- package/package.json +10 -10
- package/src/Platform.res +127 -31
- package/src/Platform.res.mjs +226 -44
- package/src/adapter/DcbEventLog/DcbBackend.res +2 -2
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res +4 -1
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream.res.mjs +1 -1
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res +14 -4
- package/src/adapter/EventCollector/EventCollectorChannel_DynamoDbStream_Runtime.res.mjs +19 -14
- package/src/adapter/EventLog/EventLogBackend.res +82 -0
- package/src/adapter/EventLog/EventLogBackend.res.mjs +53 -0
- package/src/adapter/EventLog/EventLogStorage.res +15 -0
- package/src/adapter/EventLog/EventLogStorage.res.mjs +20 -1
- package/src/adapter/EventLog/EventLogStorage_Postgres.res +37 -0
- package/src/adapter/EventLog/EventLogStorage_Postgres.res.mjs +21 -0
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res +26 -11
- package/src/adapter/Postgres/PgChangeFeedRelay_Builder.res.mjs +15 -6
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +54 -0
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +23 -0
- package/src/adapter/Postgres/PgProjectionFeed.res +100 -0
- package/src/adapter/Postgres/PgProjectionFeed.res.mjs +54 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res +281 -0
- package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +189 -0
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +335 -0
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +252 -0
- package/src/adapter/QueryDb/QueryDbBackend.res +52 -0
- package/src/adapter/QueryDb/QueryDbBackend.res.mjs +44 -0
- package/src/adapter/QueryDb/QueryDbResolvers.res +49 -0
- package/src/adapter/QueryDb/QueryDbResolvers.res.mjs +20 -1
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +264 -0
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +164 -0
- package/src/adapter/QueryDb/QueryDbStorage.res +44 -0
- package/src/adapter/QueryDb/QueryDbStorage.res.mjs +34 -1
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res +47 -0
- package/src/adapter/QueryDb/QueryDbStorage_Postgres.res.mjs +21 -0
- package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res +19 -0
- package/src/adapter/QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs +15 -0
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res +12 -1
- package/src/adapter/QueryEngine/QueryEngine_DynamoDb.res.mjs +2 -1
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res +83 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single.res.mjs +46 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res +83 -3
- package/src/adapter/Runtime/AggregateRuntime_Builder_Single_Async.res.mjs +48 -3
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +130 -5
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res.mjs +66 -6
- package/src/adapter/Runtime/PgChangeFeedRelayEntryPoint.mjs +15 -10
- package/src/adapter/Runtime/PgQueryResolverEntryPoint.mjs +88 -0
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +32 -15
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +26 -12
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res +138 -8
- package/src/adapter/Runtime/StateViewSliceRuntime_Builder_Single.res.mjs +76 -11
- package/src/components/Aggregate_Builder_Single.res +12 -5
- package/src/components/Aggregate_Builder_Single.res.mjs +12 -6
- package/src/components/Aggregate_Builder_Single_Async.res +12 -5
- package/src/components/Aggregate_Builder_Single_Async.res.mjs +12 -6
- package/src/components/ReadModel_Builder_NoResolver.res.mjs +1 -1
- package/src/components/ReadModel_Builder_NoResolver_Stream.res +13 -4
- package/src/components/ReadModel_Builder_NoResolver_Stream.res.mjs +14 -9
- package/src/components/ReadModel_Builder_Single.res +14 -5
- package/src/components/ReadModel_Builder_Single.res.mjs +15 -12
- package/src/components/ReadModel_Builder_Single_Stream.res +14 -5
- package/src/components/ReadModel_Builder_Single_Stream.res.mjs +15 -12
- package/src/components/StateViewSlice_Builder.res +10 -4
- package/src/components/StateViewSlice_Builder.res.mjs +8 -10
- package/src/components/StateViewSlice_Builder_Stream.res +10 -4
- package/src/components/StateViewSlice_Builder_Stream.res.mjs +8 -10
- package/src/plugin/runtime/PluginRuntime_Builder.res +12 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +4 -0
- package/tests/EventCollectorChannelStreamRuntimeTest.res +55 -0
- package/tests/EventCollectorChannelStreamRuntimeTest.res.mjs +78 -0
- package/tests/PgChangeFeedRelay_IntegrationTest.res +115 -4
- package/tests/PgChangeFeedRelay_IntegrationTest.res.mjs +84 -0
- package/tests/PgPipeline_IntegrationTest.res +208 -0
- package/tests/PgPipeline_IntegrationTest.res.mjs +260 -0
- package/tests/PgQueryResolver_LambdaTest.res +308 -0
- package/tests/PgQueryResolver_LambdaTest.res.mjs +392 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// Runtime handler for the shared Postgres GraphQL-read Lambda (B3.2).
|
|
2
|
+
//
|
|
3
|
+
// Postgres-backed read models have no per-table AppSync data source, so their
|
|
4
|
+
// GraphQL Query fields route through one in-VPC "PgQueryResolver" Lambda
|
|
5
|
+
// registered as an AppSync Lambda data source (B3.2b). Each resolver field's
|
|
6
|
+
// APPSYNC_JS template invokes this Lambda with
|
|
7
|
+
// { readModelName, kind, index?, arguments, identity }
|
|
8
|
+
// and this handler dispatches to the Postgres query push-downs
|
|
9
|
+
// (QueryEnginePostgres) + the shared connection spec (QueryDbListQuery).
|
|
10
|
+
//
|
|
11
|
+
// This module is the provider-agnostic *dispatch* — the headless twin of
|
|
12
|
+
// reventless-local's `QueryDbResolvers_GraphQL` (which registers graphql-yoga
|
|
13
|
+
// resolvers). It returns JSON straight to AppSync instead. The per-read-model
|
|
14
|
+
// context (ops set, push-downs, indexes, capability, …) is bound at Lambda init
|
|
15
|
+
// by the entry point (PgQueryResolverEntryPoint.mjs, B3.2b) and registered here;
|
|
16
|
+
// `dispatch` itself is pure over a `binding`, so it is unit-tested with an
|
|
17
|
+
// in-memory mock (no Postgres) — the SQL correctness lives in
|
|
18
|
+
// QueryEnginePostgres and is covered by its PG_URL-gated parity test.
|
|
19
|
+
|
|
20
|
+
let log = ReventlessCore.Logger.fromEnv()
|
|
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
|
+
|
|
28
|
+
// Wire payload from the resolver template. `arguments` mirrors the field name the
|
|
29
|
+
// other Invoke templates use (QueryInterceptor_Lambda, invokeCommandGenerator).
|
|
30
|
+
// The cross-table fields (B3.2c) are present only for resolveOne/resolveMany:
|
|
31
|
+
type payload = {
|
|
32
|
+
readModelName: string,
|
|
33
|
+
kind: string,
|
|
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
|
+
arguments: JSON.t,
|
|
45
|
+
identity: Reventless.Identity.t,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The Postgres query push-downs a binding needs (QueryEnginePostgres.Make
|
|
49
|
+
// provides these; the mock in tests provides in-memory equivalents).
|
|
50
|
+
type pushdowns = {
|
|
51
|
+
indexLookup: (~readModelName: string, string, string) => promise<array<JSON.t>>,
|
|
52
|
+
byIds: (~readModelName: string, array<string>) => promise<array<JSON.t>>,
|
|
53
|
+
listPage: (
|
|
54
|
+
~readModelName: string,
|
|
55
|
+
~argsDict: dict<JSON.t>,
|
|
56
|
+
~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
|
|
57
|
+
~labelField: string,
|
|
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>,
|
|
66
|
+
// Full materialisation for the list fallback (shapes listPage declines).
|
|
67
|
+
scanAll: (~readModelName: string) => promise<array<JSON.t>>,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Per-read-model dispatch context, bound once at Lambda init.
|
|
71
|
+
type binding = {
|
|
72
|
+
ops: ReventlessCore.QueryDb_Adapter.operations,
|
|
73
|
+
pushdowns: pushdowns,
|
|
74
|
+
indexes: array<Reventless.ReadModel.indexConfig>,
|
|
75
|
+
subIdField: option<string>,
|
|
76
|
+
capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
|
|
77
|
+
labelField: string,
|
|
78
|
+
includeIdParam: bool,
|
|
79
|
+
authorization: Reventless.Authorization.permission,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// -- arg helpers -------------------------------------------------------------
|
|
83
|
+
let argObj = (args: JSON.t): dict<JSON.t> =>
|
|
84
|
+
args->JSON.Decode.object->Option.getOr(Dict.make())
|
|
85
|
+
let argStr = (args: JSON.t, key: string): option<string> =>
|
|
86
|
+
args->argObj->Dict.get(key)->Option.flatMap(JSON.Decode.string)
|
|
87
|
+
let argStrs = (args: JSON.t, key: string): array<string> =>
|
|
88
|
+
args
|
|
89
|
+
->argObj
|
|
90
|
+
->Dict.get(key)
|
|
91
|
+
->Option.flatMap(JSON.Decode.array)
|
|
92
|
+
->Option.getOr([])
|
|
93
|
+
->Array.filterMap(JSON.Decode.string)
|
|
94
|
+
|
|
95
|
+
// Add `id` (= partition key) to an item when absent — matches the shape the
|
|
96
|
+
// DynamoDB resolvers and the shared spec return.
|
|
97
|
+
let withId = (item: JSON.t, id: string): JSON.t =>
|
|
98
|
+
switch item->JSON.Decode.object {
|
|
99
|
+
| Some(obj) if obj->Dict.get("id")->Option.isNone =>
|
|
100
|
+
let copy = Dict.copy(obj)
|
|
101
|
+
copy->Dict.set("id", JSON.Encode.string(id))
|
|
102
|
+
JSON.Encode.object(copy)
|
|
103
|
+
| _ => item
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let emptyConnection = (): JSON.t =>
|
|
107
|
+
ReventlessCore.QueryDbListQuery.buildConnection(
|
|
108
|
+
~pageItems=[],
|
|
109
|
+
~hasNextPage=false,
|
|
110
|
+
~hasPreviousPage=false,
|
|
111
|
+
~cursorValueOf=_ => "",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// Spec-level authorization then the (optional) user interceptor — mirrors
|
|
115
|
+
// QueryDbResolvers_GraphQL.runInterceptor. Denials return the kind's empty shape
|
|
116
|
+
// (the provider-agnostic resolver behaviour), not an error.
|
|
117
|
+
let runInterceptor = async (~binding, ~payload): ReventlessCore.QueryDb_Callback.interceptResult => {
|
|
118
|
+
if !Reventless.Authorization.isAllowed(binding.authorization, payload.identity) {
|
|
119
|
+
Deny("Forbidden")
|
|
120
|
+
} else {
|
|
121
|
+
switch ReventlessCore.QueryDb_Callback.queryInterceptorHook.contents {
|
|
122
|
+
| None => Allow
|
|
123
|
+
| Some(interceptor) =>
|
|
124
|
+
await interceptor(
|
|
125
|
+
~identity=payload.identity,
|
|
126
|
+
~readModelName=payload.readModelName,
|
|
127
|
+
~args=payload.arguments,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let dispatch = async (~binding: binding, ~payload: payload): JSON.t => {
|
|
134
|
+
let rm = payload.readModelName
|
|
135
|
+
switch await runInterceptor(~binding, ~payload) {
|
|
136
|
+
| Deny(_) =>
|
|
137
|
+
// Empty shape per kind (null for single, connection for list/items, [] else).
|
|
138
|
+
let isMulti = payload.multi->Option.getOr(false)
|
|
139
|
+
switch payload.kind {
|
|
140
|
+
| "getById" => JSON.Encode.null
|
|
141
|
+
| "resolveOne" if !isMulti => JSON.Encode.null
|
|
142
|
+
| "list" | "items" => emptyConnection()
|
|
143
|
+
| _ => JSON.Encode.array([])
|
|
144
|
+
}
|
|
145
|
+
| Allow =>
|
|
146
|
+
switch payload.kind {
|
|
147
|
+
| "getById" =>
|
|
148
|
+
let id = payload.arguments->argStr("id")->Option.getOr("")
|
|
149
|
+
switch await binding.ops.load(id) {
|
|
150
|
+
| Ok(items) =>
|
|
151
|
+
switch items->Array.get(0) {
|
|
152
|
+
| Some(item) => binding.includeIdParam ? withId(item, id) : item
|
|
153
|
+
| None => JSON.Encode.null
|
|
154
|
+
}
|
|
155
|
+
| Error(_) => JSON.Encode.null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
| "byIds" =>
|
|
159
|
+
let ids = payload.arguments->argStrs("ids")
|
|
160
|
+
JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=rm, ids))
|
|
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
|
+
|
|
177
|
+
| "index" =>
|
|
178
|
+
// The index arg name is the index; its stored field is idField ?? index.
|
|
179
|
+
let indexName = payload.index->Option.getOr("")
|
|
180
|
+
let field =
|
|
181
|
+
binding.indexes
|
|
182
|
+
->Array.find(ic => ic.index === indexName)
|
|
183
|
+
->Option.flatMap(ic => ic.idField)
|
|
184
|
+
->Option.getOr(indexName)
|
|
185
|
+
let value = payload.arguments->argStr(indexName)->Option.getOr("")
|
|
186
|
+
JSON.Encode.array(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))
|
|
187
|
+
|
|
188
|
+
| "list" =>
|
|
189
|
+
let argsDict = payload.arguments->argObj
|
|
190
|
+
switch await binding.pushdowns.listPage(
|
|
191
|
+
~readModelName=rm,
|
|
192
|
+
~argsDict,
|
|
193
|
+
~capability=binding.capability,
|
|
194
|
+
~labelField=binding.labelField,
|
|
195
|
+
) {
|
|
196
|
+
| Some(conn) => conn
|
|
197
|
+
| None =>
|
|
198
|
+
// Shapes listPage declines (search/searchPrefix/ids/backward) → run the
|
|
199
|
+
// shared spec over the materialised model. No Relay global-id decoding
|
|
200
|
+
// in the Lambda, so `decodeLocalId` is a no-op (the `ids` filter still
|
|
201
|
+
// matches raw item ids).
|
|
202
|
+
let items = await binding.pushdowns.scanAll(~readModelName=rm)
|
|
203
|
+
ReventlessCore.QueryDbListQuery.run(
|
|
204
|
+
~items,
|
|
205
|
+
~argsDict,
|
|
206
|
+
~capability=binding.capability,
|
|
207
|
+
~labelField=binding.labelField,
|
|
208
|
+
~decodeLocalId=_ => None,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
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
|
+
|
|
261
|
+
| other =>
|
|
262
|
+
// Fail loudly on unmapped kinds (feature-parity guard, per the B3.2 plan).
|
|
263
|
+
log.error(~comp="PgQueryResolver_Lambda", `unmapped resolver kind '${other}' for ${rm}`)
|
|
264
|
+
JsError.throwWithMessage(`PgQueryResolver: unmapped resolver kind '${other}' for ${rm}`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Per-read-model binding registry, populated by the entry point at Lambda init.
|
|
270
|
+
let bindings: dict<binding> = Dict.make()
|
|
271
|
+
let register = (~readModelName: string, binding: binding): unit =>
|
|
272
|
+
bindings->Dict.set(readModelName, binding)
|
|
273
|
+
|
|
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
|
|
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
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
7
|
+
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
8
|
+
import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
|
|
9
|
+
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
10
|
+
import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
|
|
11
|
+
|
|
12
|
+
let log = Logger$ReventlessCore.fromEnv();
|
|
13
|
+
|
|
14
|
+
function argObj(args) {
|
|
15
|
+
return Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function argStr(args, key) {
|
|
19
|
+
return Stdlib_Option.flatMap(argObj(args)[key], Stdlib_JSON.Decode.string);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function argStrs(args, key) {
|
|
23
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(argObj(args)[key], Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function withId(item, id) {
|
|
27
|
+
let obj = Stdlib_JSON.Decode.object(item);
|
|
28
|
+
if (obj === undefined) {
|
|
29
|
+
return item;
|
|
30
|
+
}
|
|
31
|
+
if (!Stdlib_Option.isNone(obj["id"])) {
|
|
32
|
+
return item;
|
|
33
|
+
}
|
|
34
|
+
let copy = Object.assign({}, obj);
|
|
35
|
+
copy["id"] = id;
|
|
36
|
+
return copy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emptyConnection() {
|
|
40
|
+
return QueryDbListQuery$ReventlessCore.buildConnection([], false, false, param => "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runInterceptor(binding, payload) {
|
|
44
|
+
if (!Authorization$Reventless.isAllowed(binding.authorization, payload.identity)) {
|
|
45
|
+
return {
|
|
46
|
+
TAG: "Deny",
|
|
47
|
+
_0: "Forbidden"
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
let interceptor = QueryDb_Callback$ReventlessCore.queryInterceptorHook.contents;
|
|
51
|
+
if (interceptor !== undefined) {
|
|
52
|
+
return await interceptor(payload.identity, payload.readModelName, payload.arguments);
|
|
53
|
+
} else {
|
|
54
|
+
return "Allow";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function dispatch(binding, payload) {
|
|
59
|
+
let rm = payload.readModelName;
|
|
60
|
+
let match = await runInterceptor(binding, payload);
|
|
61
|
+
if (typeof match !== "object") {
|
|
62
|
+
let other = payload.kind;
|
|
63
|
+
switch (other) {
|
|
64
|
+
case "byIds" :
|
|
65
|
+
let ids = argStrs(payload.arguments, "ids");
|
|
66
|
+
return await binding.pushdowns.byIds(rm, ids);
|
|
67
|
+
case "getById" :
|
|
68
|
+
let id = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
|
|
69
|
+
let items = await binding.ops.load(id);
|
|
70
|
+
if (items.TAG !== "Ok") {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
let item = items._0[0];
|
|
74
|
+
if (item !== undefined) {
|
|
75
|
+
if (binding.includeIdParam) {
|
|
76
|
+
return withId(item, id);
|
|
77
|
+
} else {
|
|
78
|
+
return item;
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
case "index" :
|
|
84
|
+
let indexName = Stdlib_Option.getOr(payload.index, "");
|
|
85
|
+
let field = Stdlib_Option.getOr(Stdlib_Option.flatMap(binding.indexes.find(ic => ic.index === indexName), ic => ic.idField), indexName);
|
|
86
|
+
let value = Stdlib_Option.getOr(argStr(payload.arguments, indexName), "");
|
|
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));
|
|
95
|
+
case "list" :
|
|
96
|
+
let argsDict = argObj(payload.arguments);
|
|
97
|
+
let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField);
|
|
98
|
+
if (conn !== undefined) {
|
|
99
|
+
return conn;
|
|
100
|
+
}
|
|
101
|
+
let items$1 = await binding.pushdowns.scanAll(rm);
|
|
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
|
+
}
|
|
140
|
+
default:
|
|
141
|
+
log.error("PgQueryResolver_Lambda", undefined, `unmapped resolver kind '` + other + `' for ` + rm);
|
|
142
|
+
return Stdlib_JsError.throwWithMessage(`PgQueryResolver: unmapped resolver kind '` + other + `' for ` + rm);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
let isMulti = Stdlib_Option.getOr(payload.multi, false);
|
|
146
|
+
let match$3 = payload.kind;
|
|
147
|
+
switch (match$3) {
|
|
148
|
+
case "getById" :
|
|
149
|
+
return null;
|
|
150
|
+
case "items" :
|
|
151
|
+
case "list" :
|
|
152
|
+
return emptyConnection();
|
|
153
|
+
case "resolveOne" :
|
|
154
|
+
if (isMulti) {
|
|
155
|
+
return [];
|
|
156
|
+
} else {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
default:
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let bindings = {};
|
|
166
|
+
|
|
167
|
+
function register(readModelName, binding) {
|
|
168
|
+
bindings[readModelName] = binding;
|
|
169
|
+
}
|
|
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
|
+
|
|
213
|
+
async function handler(payload, _context) {
|
|
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];
|
|
229
|
+
if (binding !== undefined) {
|
|
230
|
+
return await dispatch(binding, payload);
|
|
231
|
+
} else {
|
|
232
|
+
return Stdlib_JsError.throwWithMessage("PgQueryResolver: no binding registered for read model " + bindingKey);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export {
|
|
237
|
+
log,
|
|
238
|
+
argObj,
|
|
239
|
+
argStr,
|
|
240
|
+
argStrs,
|
|
241
|
+
withId,
|
|
242
|
+
emptyConnection,
|
|
243
|
+
runInterceptor,
|
|
244
|
+
dispatch,
|
|
245
|
+
bindings,
|
|
246
|
+
register,
|
|
247
|
+
nodeTypeMap,
|
|
248
|
+
registerNodeType,
|
|
249
|
+
handleNode,
|
|
250
|
+
handler,
|
|
251
|
+
}
|
|
252
|
+
/* log Not a pure module */
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// AWS-side selection for the QueryDb (read model) storage backend — the QueryDb
|
|
2
|
+
// analogue of `EventLogBackend` / `DcbBackend` (B3.1,
|
|
3
|
+
// docs/plans/aws-postgres-querydb-adapter.md).
|
|
4
|
+
//
|
|
5
|
+
// When `Platform.MakeWithConfig` is given a `~pgConnection`, it records the
|
|
6
|
+
// resolved selection here BEFORE any admin/plugin `construct` runs. Read by:
|
|
7
|
+
// - `QueryDbStorage.Selectable(Stream)` — route app read-model storage to
|
|
8
|
+
// Postgres (no DynamoDB table) and bind the Postgres operation set;
|
|
9
|
+
// - `QueryDbResolvers.Selectable` — suppress AppSync resolvers for
|
|
10
|
+
// Postgres-backed read models (GraphQL reads land with B3.2's Lambda data
|
|
11
|
+
// source; until then those fields are unresolved);
|
|
12
|
+
// - the ReadModel/StateViewSlice runtime builders — inject `pgConnection` into
|
|
13
|
+
// Postgres-backed handlers' HANDLER_CONFIG entries and put the projection
|
|
14
|
+
// Lambdas in-VPC with secret access.
|
|
15
|
+
//
|
|
16
|
+
// ADMIN EXEMPTION: platform/admin read models (Plugins, UIFragmentRegistry, …)
|
|
17
|
+
// stay on DynamoDB even when Postgres is selected. Deploy-time consumers (the
|
|
18
|
+
// AppSync schema-clobber guard's Plugin-RM scan, `PLUGIN_RM_TABLE_NAME` gates,
|
|
19
|
+
// retire hooks) query these tables during `pulumi up` — from outside the VPC —
|
|
20
|
+
// and the whole platform lifecycle depends on them. The platform registers the
|
|
21
|
+
// exempt names before Admin.construct.
|
|
22
|
+
|
|
23
|
+
type selection = {
|
|
24
|
+
connectionConfig: Pulumi.Output.t<PgConnection.connectionConfig>,
|
|
25
|
+
/** DB-access security group every projection Lambda attaches (PgConnection.securityGroupId). */
|
|
26
|
+
securityGroupId: Pulumi.Output.t<string>,
|
|
27
|
+
/** Private subnets for the in-VPC projection Lambdas (PgConnection.subnetIds). */
|
|
28
|
+
subnetIds: array<Pulumi.Input.t<string>>,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let selectionRef: ref<option<selection>> = ref(None)
|
|
32
|
+
|
|
33
|
+
/** Record the QueryDb Postgres selection. Called once by the platform before construct. */
|
|
34
|
+
let set = (sel: selection) => selectionRef := Some(sel)
|
|
35
|
+
|
|
36
|
+
/** The active QueryDb Postgres selection, if the platform was given a `PgConnection`. */
|
|
37
|
+
let get = (): option<selection> => selectionRef.contents
|
|
38
|
+
|
|
39
|
+
// Read-model names that stay on DynamoDB regardless of the selection.
|
|
40
|
+
let exemptNames: array<string> = []
|
|
41
|
+
|
|
42
|
+
/** Exempt an (admin) read model from the Postgres selection. Called by the
|
|
43
|
+
platform for every admin read model before Admin.construct. */
|
|
44
|
+
let exempt = (name: string) => exemptNames->Array.push(name)->ignore
|
|
45
|
+
|
|
46
|
+
/** Whether this read model's QueryDb is Postgres-backed on this platform. */
|
|
47
|
+
let isPostgresFor = (name: string): bool =>
|
|
48
|
+
selectionRef.contents->Option.isSome && !(exemptNames->Array.includes(name))
|
|
49
|
+
|
|
50
|
+
/** Whether ANY read model on this platform is Postgres-backed (used to decide
|
|
51
|
+
whether the projection Lambdas need VPC/secret access). */
|
|
52
|
+
let isPostgres = (): bool => selectionRef.contents->Option.isSome
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
|
|
5
|
+
let selectionRef = {
|
|
6
|
+
contents: undefined
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function set(sel) {
|
|
10
|
+
selectionRef.contents = sel;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function get() {
|
|
14
|
+
return selectionRef.contents;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let exemptNames = [];
|
|
18
|
+
|
|
19
|
+
function exempt(name) {
|
|
20
|
+
exemptNames.push(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPostgresFor(name) {
|
|
24
|
+
if (Stdlib_Option.isSome(selectionRef.contents)) {
|
|
25
|
+
return !exemptNames.includes(name);
|
|
26
|
+
} else {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPostgres() {
|
|
32
|
+
return Stdlib_Option.isSome(selectionRef.contents);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
selectionRef,
|
|
37
|
+
set,
|
|
38
|
+
get,
|
|
39
|
+
exemptNames,
|
|
40
|
+
exempt,
|
|
41
|
+
isPostgresFor,
|
|
42
|
+
isPostgres,
|
|
43
|
+
}
|
|
44
|
+
/* No side effect */
|