@reventlessdev/rescript-pulumi-aws 2.4.0-alpha.78 → 2.4.0-alpha.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 2.4.0-alpha.80 (2026-08-18)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** make the unowned stub callable on the doors that call it ([931aa39](https://github.com/ReventlessDev/reventless-core/commit/931aa3968ae0e29befab7183125ed2453d4765cb))
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **aws:** compile every resolver against AppSync before the deploy attaches one ([635bc26](https://github.com/ReventlessDev/reventless-core/commit/635bc265c63e19f1a69f31dfefe116b523b1c39b))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# 2.4.0-alpha.79 (2026-08-18)
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **api:** make the by-index door answer, and let an elevated caller widen it ([0fe0c6f](https://github.com/ReventlessDev/reventless-core/commit/0fe0c6f8dec6228ecaba39577e28d780b4f79c83))
|
|
21
|
+
* **aws:** apply [@owner](https://github.com/owner) on the DynamoDB by-ids and by-index doors ([a6b5afc](https://github.com/ReventlessDev/reventless-core/commit/a6b5afc2923bfa4d3e0b2990367f67e8ffdd8877))
|
|
22
|
+
* **aws:** narrow retirement on every DynamoDB door, not only the list ([d6a799b](https://github.com/ReventlessDev/reventless-core/commit/d6a799b287b28e9c1f75e193adbf3f6328a6bf2d))
|
|
23
|
+
### Features
|
|
24
|
+
|
|
25
|
+
* **core:** let a reference name a retired row, and let an elevated caller open one ([9e2623a](https://github.com/ReventlessDev/reventless-core/commit/9e2623a4b22487561607fcc0ca19d51726069ee4))
|
|
26
|
+
|
|
27
|
+
|
|
6
28
|
# 2.4.0-alpha.78 (2026-08-16)
|
|
7
29
|
|
|
8
30
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/rescript-pulumi-aws",
|
|
3
|
-
"version": "2.4.0-alpha.
|
|
3
|
+
"version": "2.4.0-alpha.80",
|
|
4
4
|
"description": "ReScript bindings for @pulumi/aws",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"@pulumi/aws": "~7.19.0",
|
|
23
23
|
"@pulumi/aws-native": "^1.62.0",
|
|
24
24
|
"sury": "11.0.0-alpha.4",
|
|
25
|
-
"@reventlessdev/rescript-
|
|
26
|
-
"@reventlessdev/rescript-
|
|
25
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
26
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.12"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"esbuild": "^0.25.12",
|
|
@@ -55,6 +55,155 @@ let ownerGuardPreamble = (~ownerField: string, ~elevatedGroups: array<string>) =
|
|
|
55
55
|
const _owns = (row) => row == null || _exempt || row['${ownerField}'] === _sub;`
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
The exemption test on its own, for a door that narrows retirement but has no
|
|
60
|
+
`@owner` field to have declared it already.
|
|
61
|
+
|
|
62
|
+
Identical to the three lines `ownerGuardPreamble` opens with, and emitted only
|
|
63
|
+
when that preamble is absent — two `const _exempt` in one function body is a
|
|
64
|
+
syntax error, and two *different* definitions of exempt would be worse than one.
|
|
65
|
+
*/
|
|
66
|
+
let exemptPreamble = (~elevatedGroups: array<string>) => {
|
|
67
|
+
let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
|
|
68
|
+
`
|
|
69
|
+
const _id = ctx.identity;
|
|
70
|
+
const _sub = _id == null ? null : _id.sub;
|
|
71
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
72
|
+
const _elevated = [${elevatedLiteral}];
|
|
73
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
A by-key read's retirement guard: `_live(row)`, true when the caller may see it.
|
|
78
|
+
|
|
79
|
+
The post-read half of the predicate `listAllItemsConnection` pushes into a
|
|
80
|
+
FilterExpression. `GetItem` and `BatchGetItem` have no filter to push into — the
|
|
81
|
+
row is fetched by key — so the decision is made on what came back, which is where
|
|
82
|
+
the owner guard beside it already makes its own.
|
|
83
|
+
|
|
84
|
+
`row[field] == null` keeps a row written before the annotation existed, matching
|
|
85
|
+
the `attribute_not_exists` half of the list's clause and every other adapter's
|
|
86
|
+
reading of an absent value.
|
|
87
|
+
|
|
88
|
+
Asking is required, not merely permitted: `_exempt` alone leaves a retired row
|
|
89
|
+
withheld until `includeRetired` is passed, so an operator's ordinary read is as
|
|
90
|
+
narrow as anyone's. An archive that is always underfoot is not an archive.
|
|
91
|
+
|
|
92
|
+
`~ownerScoped` says whether an `ownerGuardPreamble` has already been emitted into
|
|
93
|
+
the same body; when it has, this reuses its `_exempt` instead of redeclaring one.
|
|
94
|
+
*/
|
|
95
|
+
let retiredGuardPreamble = (
|
|
96
|
+
~retiredField: option<string>,
|
|
97
|
+
~retiredValues: option<array<string>>,
|
|
98
|
+
~elevatedGroups: array<string>,
|
|
99
|
+
~ownerScoped: bool,
|
|
100
|
+
) =>
|
|
101
|
+
switch retiredField {
|
|
102
|
+
| None => ""
|
|
103
|
+
| Some(field) =>
|
|
104
|
+
let prelude = ownerScoped ? "" : exemptPreamble(~elevatedGroups)
|
|
105
|
+
let isRetired = switch retiredValues {
|
|
106
|
+
| None => `row['${field}'] === true`
|
|
107
|
+
| Some(states) =>
|
|
108
|
+
let literal = states->Array.map(s => `'${s}'`)->Array.join(", ")
|
|
109
|
+
`[${literal}].indexOf(row['${field}']) >= 0`
|
|
110
|
+
}
|
|
111
|
+
`${prelude}
|
|
112
|
+
// ── retirement narrowing (generated) ──
|
|
113
|
+
const _wantsRetired = _exempt && ctx.args.includeRetired === true;
|
|
114
|
+
const _live = (row) =>
|
|
115
|
+
row == null || _wantsRetired || row['${field}'] == null || !(${isRetired});`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
The `@owner` predicate for an index door, as a FilterExpression clause.
|
|
120
|
+
|
|
121
|
+
Same rule and same branch order as `listAllItemsConnection`'s own owner clause —
|
|
122
|
+
provider before identity, because an IAM-signed service caller has no `sub` for a
|
|
123
|
+
reason that has nothing to do with being anonymous — and pushed into the read for
|
|
124
|
+
the same reason: an index door takes a `limit`, and a page cut before the
|
|
125
|
+
predicate would come back short with nothing said about why.
|
|
126
|
+
|
|
127
|
+
**Not applied to a group-restricted index.** Where `indexConfig.authorization` is
|
|
128
|
+
set, the door already runs `authorizeIndexedAccess`: the caller must be in the
|
|
129
|
+
named group AND be the holder the auth table records for that index value. Those
|
|
130
|
+
rows are, by construction, other people's — an order assigned to a fulfilment
|
|
131
|
+
operator is owned by the customer who placed it — so ANDing `@owner` on top would
|
|
132
|
+
return nothing and revoke exactly the access the auth table was written to grant.
|
|
133
|
+
An explicit per-index rule is the deployment's answer for that door; this is the
|
|
134
|
+
default for doors that have none. `QueryDbResolvers_AppSync` decides which is
|
|
135
|
+
which and passes `ownerField` only for the latter.
|
|
136
|
+
*/
|
|
137
|
+
let ownerFilterClause = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
|
|
138
|
+
switch ownerField {
|
|
139
|
+
| None => ""
|
|
140
|
+
| Some(field) =>
|
|
141
|
+
let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
|
|
142
|
+
`
|
|
143
|
+
// ── owner scoping (generated) ──
|
|
144
|
+
// Not read from ctx.args: a predicate deciding what the caller may see must
|
|
145
|
+
// arrive on a channel the caller cannot name.
|
|
146
|
+
const _oid = ctx.identity;
|
|
147
|
+
const _osub = _oid == null ? null : _oid.sub;
|
|
148
|
+
const _ogroups = (_oid != null && _oid.claims != null && _oid.claims['cognito:groups']) || [];
|
|
149
|
+
const _oelevated = [${elevatedLiteral}];
|
|
150
|
+
if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0))) {
|
|
151
|
+
if (expression) expression += ' AND ';
|
|
152
|
+
names['#owner'] = '${field}';
|
|
153
|
+
values[':owner'] = _osub;
|
|
154
|
+
expression += '#owner = :owner';
|
|
155
|
+
}
|
|
156
|
+
`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
The same retirement predicate as `retiredGuardPreamble`, for a door that reads
|
|
161
|
+
with `Query` and therefore has a FilterExpression to put it in.
|
|
162
|
+
|
|
163
|
+
Pushed into the read rather than applied to what came back, on
|
|
164
|
+
`listAllItemsConnection`'s reasoning: an index door takes a `limit`, and
|
|
165
|
+
narrowing after the read would hand back a page of fewer rows than the caller
|
|
166
|
+
asked for while reporting nothing about why.
|
|
167
|
+
|
|
168
|
+
Emits JS that appends to the `expression` / `names` / `values` the index
|
|
169
|
+
templates already build, so it composes with a caller's own filter arguments
|
|
170
|
+
instead of replacing them.
|
|
171
|
+
*/
|
|
172
|
+
let retiredFilterClause = (
|
|
173
|
+
~retiredField: option<string>,
|
|
174
|
+
~retiredValues: option<array<string>>,
|
|
175
|
+
~elevatedGroups: array<string>,
|
|
176
|
+
) =>
|
|
177
|
+
switch retiredField {
|
|
178
|
+
| None => ""
|
|
179
|
+
| Some(field) =>
|
|
180
|
+
let assignments = switch retiredValues {
|
|
181
|
+
| None => ` values[':retiredFalse'] = false;
|
|
182
|
+
expression += '(attribute_not_exists(#retired) OR #retired = :retiredFalse)';`
|
|
183
|
+
| Some(states) =>
|
|
184
|
+
let assigns =
|
|
185
|
+
states
|
|
186
|
+
->Array.mapWithIndex((state, i) =>
|
|
187
|
+
` values[':retiredValue${Int.toString(i)}'] = '${state}';`
|
|
188
|
+
)
|
|
189
|
+
->Array.join("\n")
|
|
190
|
+
let comparisons =
|
|
191
|
+
states
|
|
192
|
+
->Array.mapWithIndex((_, i) => `#retired <> :retiredValue${Int.toString(i)}`)
|
|
193
|
+
->Array.join(" AND ")
|
|
194
|
+
`${assigns}
|
|
195
|
+
expression += '(attribute_not_exists(#retired) OR (${comparisons}))';`
|
|
196
|
+
}
|
|
197
|
+
`${exemptPreamble(~elevatedGroups)}
|
|
198
|
+
// ── retirement narrowing (generated) ──
|
|
199
|
+
if (!(_exempt && ctx.args.includeRetired === true)) {
|
|
200
|
+
if (expression) expression += ' AND ';
|
|
201
|
+
names['#retired'] = '${field}';
|
|
202
|
+
${assignments}
|
|
203
|
+
}
|
|
204
|
+
`
|
|
205
|
+
}
|
|
206
|
+
|
|
58
207
|
/**
|
|
59
208
|
A by-key read's response, refusing a row the caller does not own.
|
|
60
209
|
|
|
@@ -66,29 +215,73 @@ the failure mode owner scoping exists to avoid. And an error would confirm the
|
|
|
66
215
|
row exists to a caller who may not read it, which is a worse leak than the
|
|
67
216
|
ambiguity it removes.
|
|
68
217
|
*/
|
|
69
|
-
let ownerScopedResultResponse = (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
218
|
+
let ownerScopedResultResponse = (
|
|
219
|
+
~ownerField: option<string>,
|
|
220
|
+
~elevatedGroups: array<string>,
|
|
221
|
+
~retiredField: option<string>=?,
|
|
222
|
+
~retiredValues: option<array<string>>=?,
|
|
223
|
+
) =>
|
|
224
|
+
switch (ownerField, retiredField) {
|
|
225
|
+
| (None, None) => resultResponseCode
|
|
226
|
+
| _ =>
|
|
227
|
+
let ownerPart = switch ownerField {
|
|
228
|
+
// Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
|
|
229
|
+
// zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
|
|
230
|
+
// arguments, but got 1") and AppSync rejects the whole resolver at create
|
|
231
|
+
// time with "The code contains one or more errors". Only a door that emits
|
|
232
|
+
// the call conditionally is safe with a bare `() => true`, and that is not a
|
|
233
|
+
// property worth relying on across three templates.
|
|
234
|
+
| None => "\n const _owns = (row) => true;"
|
|
235
|
+
| Some(field) =>
|
|
236
|
+
`
|
|
237
|
+
// ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
|
|
238
|
+
}
|
|
239
|
+
let retiredPart = retiredGuardPreamble(
|
|
240
|
+
~retiredField,
|
|
241
|
+
~retiredValues,
|
|
242
|
+
~elevatedGroups,
|
|
243
|
+
~ownerScoped=ownerField->Option.isSome,
|
|
244
|
+
)
|
|
73
245
|
`
|
|
74
246
|
export function response(ctx) {
|
|
75
|
-
if (ctx.error) util.error(ctx.error.message, ctx.error.type)
|
|
76
|
-
|
|
77
|
-
return _owns(ctx.result) ? ctx.result : null;
|
|
247
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
|
|
248
|
+
return _owns(ctx.result)${retiredField->Option.isSome ? " && _live(ctx.result)" : ""} ? ctx.result : null;
|
|
78
249
|
}`
|
|
79
250
|
}
|
|
80
251
|
|
|
81
252
|
/** The `queryByIdSort` counterpart — same rule, over the first row of a Query. */
|
|
82
|
-
let ownerScopedFirstResultResponse = (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
253
|
+
let ownerScopedFirstResultResponse = (
|
|
254
|
+
~ownerField: option<string>,
|
|
255
|
+
~elevatedGroups: array<string>,
|
|
256
|
+
~retiredField: option<string>=?,
|
|
257
|
+
~retiredValues: option<array<string>>=?,
|
|
258
|
+
) =>
|
|
259
|
+
switch (ownerField, retiredField) {
|
|
260
|
+
| (None, None) => firstResultResponseCode
|
|
261
|
+
| _ =>
|
|
262
|
+
let ownerPart = switch ownerField {
|
|
263
|
+
// Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
|
|
264
|
+
// zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
|
|
265
|
+
// arguments, but got 1") and AppSync rejects the whole resolver at create
|
|
266
|
+
// time with "The code contains one or more errors". Only a door that emits
|
|
267
|
+
// the call conditionally is safe with a bare `() => true`, and that is not a
|
|
268
|
+
// property worth relying on across three templates.
|
|
269
|
+
| None => "\n const _owns = (row) => true;"
|
|
270
|
+
| Some(field) =>
|
|
271
|
+
`
|
|
272
|
+
// ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
|
|
273
|
+
}
|
|
274
|
+
let retiredPart = retiredGuardPreamble(
|
|
275
|
+
~retiredField,
|
|
276
|
+
~retiredValues,
|
|
277
|
+
~elevatedGroups,
|
|
278
|
+
~ownerScoped=ownerField->Option.isSome,
|
|
279
|
+
)
|
|
86
280
|
`
|
|
87
281
|
export function response(ctx) {
|
|
88
|
-
if (ctx.error) util.error(ctx.error.message, ctx.error.type)
|
|
89
|
-
// ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}
|
|
282
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
|
|
90
283
|
const _row = ctx.result.items[0] ?? null;
|
|
91
|
-
return _owns(_row) ? _row : null;
|
|
284
|
+
return _owns(_row)${retiredField->Option.isSome ? " && _live(_row)" : ""} ? _row : null;
|
|
92
285
|
}`
|
|
93
286
|
}
|
|
94
287
|
|
|
@@ -107,22 +300,23 @@ ${resultResponseCode}
|
|
|
107
300
|
// Relay Node resolver — pipeline functions for node(id: ID!) query
|
|
108
301
|
// ---------------------------------------------------------------------------
|
|
109
302
|
|
|
110
|
-
/** Pipeline function (NONE datasource): decodes global ID and stashes typeName + localId.
|
|
303
|
+
/** Pipeline function (NONE datasource): decodes global ID and stashes typeName + localId.
|
|
304
|
+
|
|
305
|
+
Written without a `try`: APPSYNC_JS rejects try statements outright
|
|
306
|
+
(`@aws-appsync/no-try`), so a guarded version of this cannot be deployed at
|
|
307
|
+
all. Nothing is lost by dropping it — `util.base64Decode` does not throw on
|
|
308
|
+
malformed input, it returns the bytes it made of it, so the catch could never
|
|
309
|
+
run. An id that decodes to no `type:localId` pair therefore fails the one way
|
|
310
|
+
that is left: both halves stash as null, which is also what the guarded
|
|
311
|
+
version reported for a decode it could not parse. */
|
|
111
312
|
let nodeDecodeGlobalId =
|
|
112
313
|
`${importUtil}
|
|
113
314
|
export function request(ctx) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
ctx.stash.typeName = decoded.substring(0, colonIdx);
|
|
120
|
-
ctx.stash.localId = decoded.substring(colonIdx + 1);
|
|
121
|
-
}
|
|
122
|
-
} catch (e) {
|
|
123
|
-
ctx.stash.typeName = null;
|
|
124
|
-
ctx.stash.localId = null;
|
|
125
|
-
}
|
|
315
|
+
const decoded = util.base64Decode(ctx.args.id);
|
|
316
|
+
const colonIdx = decoded.indexOf(':');
|
|
317
|
+
const parsed = colonIdx > 0;
|
|
318
|
+
ctx.stash.typeName = parsed ? decoded.substring(0, colonIdx) : null;
|
|
319
|
+
ctx.stash.localId = parsed ? decoded.substring(colonIdx + 1) : null;
|
|
126
320
|
return { payload: null };
|
|
127
321
|
}
|
|
128
322
|
export function response(ctx) {
|
|
@@ -157,7 +351,12 @@ export function response(ctx) {
|
|
|
157
351
|
// declares no owner emits exactly the source it emitted before scoping existed.
|
|
158
352
|
// A list that scopes beside a by-id read that does not is not a partial
|
|
159
353
|
// delivery — it is a hole, reachable by anyone who can name a row.
|
|
160
|
-
let getItemById = (
|
|
354
|
+
let getItemById = (
|
|
355
|
+
~ownerField: option<string>=?,
|
|
356
|
+
~elevatedGroups: array<string>=[],
|
|
357
|
+
~retiredField: option<string>=?,
|
|
358
|
+
~retiredValues: option<array<string>>=?,
|
|
359
|
+
) =>
|
|
161
360
|
`${importUtil}
|
|
162
361
|
export function request(ctx) {
|
|
163
362
|
return {
|
|
@@ -165,7 +364,7 @@ export function request(ctx) {
|
|
|
165
364
|
key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
|
|
166
365
|
};
|
|
167
366
|
}
|
|
168
|
-
${ownerScopedResultResponse(~ownerField, ~elevatedGroups)}
|
|
367
|
+
${ownerScopedResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
|
|
169
368
|
`->Pulumi.Input.make
|
|
170
369
|
|
|
171
370
|
let queryById =
|
|
@@ -300,6 +499,8 @@ let queryByIdSort = (
|
|
|
300
499
|
sortField: string,
|
|
301
500
|
~ownerField: option<string>=?,
|
|
302
501
|
~elevatedGroups: array<string>=[],
|
|
502
|
+
~retiredField: option<string>=?,
|
|
503
|
+
~retiredValues: option<array<string>>=?,
|
|
303
504
|
) =>
|
|
304
505
|
`${importUtil}
|
|
305
506
|
export function request(ctx) {
|
|
@@ -315,7 +516,7 @@ export function request(ctx) {
|
|
|
315
516
|
}
|
|
316
517
|
};
|
|
317
518
|
}
|
|
318
|
-
${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups)}
|
|
519
|
+
${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
|
|
319
520
|
`->Pulumi.Input.make
|
|
320
521
|
|
|
321
522
|
// ---------------------------------------------------------------------------
|
|
@@ -384,14 +585,84 @@ export function request(ctx) {
|
|
|
384
585
|
${resultResponseCode}
|
|
385
586
|
`->Pulumi.Input.make
|
|
386
587
|
|
|
588
|
+
/**
|
|
589
|
+
The by-index door's response.
|
|
590
|
+
|
|
591
|
+
Cursors are the DynamoDB continuation token carrying the row's position in the
|
|
592
|
+
page, exactly as `listAllItemsConnection` builds them — the two doors page over
|
|
593
|
+
the same kind of result, so they page the same way. The boundary cursor covers
|
|
594
|
+
the case that door documents: a filtered page can come back empty while a token
|
|
595
|
+
is still set, and a client has to be able to resume past it.
|
|
596
|
+
|
|
597
|
+
This replaces returning `ctx.result` raw. The field has always been declared as
|
|
598
|
+
returning a `Connection!`, and handing back DynamoDB's `{items, nextToken}`
|
|
599
|
+
satisfied no part of that contract.
|
|
600
|
+
*/
|
|
601
|
+
let indexConnectionResponseCode = `
|
|
602
|
+
export function response(ctx) {
|
|
603
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);
|
|
604
|
+
const items = ctx.result?.items ?? [];
|
|
605
|
+
const next = ctx.result?.nextToken ?? null;
|
|
606
|
+
const edges = items.map((item, i) => ({
|
|
607
|
+
node: item,
|
|
608
|
+
cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
|
|
609
|
+
}));
|
|
610
|
+
const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
|
|
611
|
+
return {
|
|
612
|
+
edges,
|
|
613
|
+
pageInfo: {
|
|
614
|
+
hasNextPage: !!next,
|
|
615
|
+
hasPreviousPage: !!ctx.args.after,
|
|
616
|
+
startCursor: edges.length > 0 ? edges[0].cursor : boundary,
|
|
617
|
+
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
|
|
618
|
+
},
|
|
619
|
+
};
|
|
620
|
+
}`
|
|
621
|
+
|
|
622
|
+
/** Refuses backward paging, for the reason `listAllItemsConnection` gives: the
|
|
623
|
+
cursor is DynamoDB's own continuation token, which only walks forward, so
|
|
624
|
+
`last`/`before` cannot be honoured and handing back the forward page would answer
|
|
625
|
+
a different question without saying so.
|
|
626
|
+
|
|
627
|
+
The arguments stay declared — one that came and went with the index's shape would
|
|
628
|
+
make every client feature-detect — and the local backend refuses them with the
|
|
629
|
+
same message, so the door reads the same either side of a deploy. */
|
|
630
|
+
let indexBackwardPagingGuard = `
|
|
631
|
+
if (args.before != null || args.last != null) {
|
|
632
|
+
util.error('Backward pagination (last/before) is not supported on by-index connections; use first/after.', 'UnsupportedPagination');
|
|
633
|
+
}`
|
|
634
|
+
|
|
635
|
+
/** Decodes the Relay `after` cursor back to the DynamoDB continuation token the
|
|
636
|
+
response side encoded. Mirrors `listAllItemsConnection`'s request half. */
|
|
637
|
+
let indexCursorPreamble = `
|
|
638
|
+
let after = null;
|
|
639
|
+
if (args.after != null && args.after !== '') {
|
|
640
|
+
const parsed = JSON.parse(util.base64Decode(args.after));
|
|
641
|
+
after = parsed.token ?? null;
|
|
642
|
+
}`
|
|
643
|
+
|
|
644
|
+
// The arguments the by-index door declares, none of which is a column to match
|
|
645
|
+
// on. `includeRetired` is a request to lift a restriction and the rest are
|
|
646
|
+
// paging; left unlisted, the filter loop below turns each into a
|
|
647
|
+
// `contains(#arg, :arg)` against an attribute no row carries, and the door
|
|
648
|
+
// answers nothing.
|
|
649
|
+
let indexReservedArgs = `key === 'first' || key === 'after' || key === 'last' || key === 'before' || key === 'includeRetired' || key === 'limit' || key === 'nextToken' || key === 'forward'`
|
|
650
|
+
|
|
387
651
|
// AppSync JS runtime restrictions (APPSYNC_JS 1.0.0):
|
|
388
652
|
// - No `for` loops (for/for-of/for-in all fail validation)
|
|
389
653
|
// - No String() / .toString() — use '' + value instead
|
|
390
654
|
// - Object.keys().forEach() works for iteration
|
|
391
|
-
let queryByIndexFiltered = (
|
|
655
|
+
let queryByIndexFiltered = (
|
|
656
|
+
~index: string,
|
|
657
|
+
~idField: string,
|
|
658
|
+
~ownerField: option<string>=?,
|
|
659
|
+
~retiredField: option<string>=?,
|
|
660
|
+
~retiredValues: option<array<string>>=?,
|
|
661
|
+
~elevatedGroups: array<string>=[],
|
|
662
|
+
) =>
|
|
392
663
|
`${importUtil}
|
|
393
664
|
export function request(ctx) {
|
|
394
|
-
const args = ctx.args
|
|
665
|
+
const args = ctx.args;${indexBackwardPagingGuard}
|
|
395
666
|
const query = {
|
|
396
667
|
expression: '#${idField} = :${idField}',
|
|
397
668
|
expressionNames: { '#${idField}': '${idField}' },
|
|
@@ -402,7 +673,7 @@ export function request(ctx) {
|
|
|
402
673
|
const values = {};
|
|
403
674
|
Object.keys(args).forEach(key => {
|
|
404
675
|
const value = args[key];
|
|
405
|
-
if (key === '${idField}' ||
|
|
676
|
+
if (key === '${idField}' || ${indexReservedArgs}) return;
|
|
406
677
|
if (value == null || value === '') return;
|
|
407
678
|
if (expression) expression += ' AND';
|
|
408
679
|
if (key === 'hideDeleted') {
|
|
@@ -417,12 +688,13 @@ export function request(ctx) {
|
|
|
417
688
|
values[':' + key] = '' + value;
|
|
418
689
|
}
|
|
419
690
|
});
|
|
691
|
+
${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
|
|
420
692
|
const result = {
|
|
421
693
|
operation: 'Query',
|
|
422
694
|
query,
|
|
423
695
|
index: '${index}',
|
|
424
|
-
limit: (args.
|
|
425
|
-
nextToken:
|
|
696
|
+
limit: (args.first ?? 50),
|
|
697
|
+
nextToken: after,
|
|
426
698
|
scanIndexForward: (args.forward ?? true)
|
|
427
699
|
};
|
|
428
700
|
if (expression) {
|
|
@@ -430,13 +702,21 @@ export function request(ctx) {
|
|
|
430
702
|
}
|
|
431
703
|
return result;
|
|
432
704
|
}
|
|
433
|
-
${
|
|
705
|
+
${indexConnectionResponseCode}
|
|
434
706
|
`->Pulumi.Input.make
|
|
435
707
|
|
|
436
|
-
let queryByIndexSortFiltered = (
|
|
708
|
+
let queryByIndexSortFiltered = (
|
|
709
|
+
~index: string,
|
|
710
|
+
~idField: string,
|
|
711
|
+
~ownerField: option<string>=?,
|
|
712
|
+
~sortField: string,
|
|
713
|
+
~retiredField: option<string>=?,
|
|
714
|
+
~retiredValues: option<array<string>>=?,
|
|
715
|
+
~elevatedGroups: array<string>=[],
|
|
716
|
+
) =>
|
|
437
717
|
`${importUtil}
|
|
438
718
|
export function request(ctx) {
|
|
439
|
-
const args = ctx.args
|
|
719
|
+
const args = ctx.args;${indexBackwardPagingGuard}
|
|
440
720
|
const query = args.${sortField}
|
|
441
721
|
? {
|
|
442
722
|
expression: '#${idField} = :${idField} AND #${sortField} = :${sortField}',
|
|
@@ -456,7 +736,7 @@ export function request(ctx) {
|
|
|
456
736
|
const values = {};
|
|
457
737
|
Object.keys(args).forEach(key => {
|
|
458
738
|
const value = args[key];
|
|
459
|
-
if (key === '${idField}' || key === '${sortField}' ||
|
|
739
|
+
if (key === '${idField}' || key === '${sortField}' || ${indexReservedArgs}) return;
|
|
460
740
|
if (value == null || value === '') return;
|
|
461
741
|
if (expression) expression += ' AND';
|
|
462
742
|
if (key === 'hideDeleted') {
|
|
@@ -471,12 +751,13 @@ export function request(ctx) {
|
|
|
471
751
|
values[':' + key] = '' + value;
|
|
472
752
|
}
|
|
473
753
|
});
|
|
754
|
+
${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
|
|
474
755
|
const result = {
|
|
475
756
|
operation: 'Query',
|
|
476
757
|
query,
|
|
477
758
|
index: '${index}',
|
|
478
|
-
limit: (args.
|
|
479
|
-
nextToken:
|
|
759
|
+
limit: (args.first ?? 50),
|
|
760
|
+
nextToken: after,
|
|
480
761
|
scanIndexForward: (args.forward ?? true)
|
|
481
762
|
};
|
|
482
763
|
if (expression) {
|
|
@@ -484,7 +765,7 @@ export function request(ctx) {
|
|
|
484
765
|
}
|
|
485
766
|
return result;
|
|
486
767
|
}
|
|
487
|
-
${
|
|
768
|
+
${indexConnectionResponseCode}
|
|
488
769
|
`->Pulumi.Input.make
|
|
489
770
|
|
|
490
771
|
// ---------------------------------------------------------------------------
|
|
@@ -998,7 +1279,12 @@ export function response(ctx) {
|
|
|
998
1279
|
time, since BatchGetItem's `tables` map keys on the literal table name.
|
|
999
1280
|
Single-key tables only — composite-key BatchGetItem needs both pk + sk per
|
|
1000
1281
|
key entry, which this template doesn't construct. */
|
|
1001
|
-
let batchGetItemsByIds = (
|
|
1282
|
+
let batchGetItemsByIds = (
|
|
1283
|
+
~ownerField: option<string>=?,
|
|
1284
|
+
~retiredField: option<string>=?,
|
|
1285
|
+
~retiredValues: option<array<string>>=?,
|
|
1286
|
+
~elevatedGroups: array<string>=[],
|
|
1287
|
+
) => (tableName: string) =>
|
|
1002
1288
|
`${importUtil}
|
|
1003
1289
|
import { runtime } from '@aws-appsync/utils';
|
|
1004
1290
|
export function request(ctx) {
|
|
@@ -1022,9 +1308,103 @@ export function response(ctx) {
|
|
|
1022
1308
|
// missing id makes the entire field fail with "Cannot return null for
|
|
1023
1309
|
// non-nullable type" and the caller sees data=null. Filter the nulls so
|
|
1024
1310
|
// the field returns just the items that were found.
|
|
1025
|
-
|
|
1311
|
+
// The owner and retirement guards the list pushes into a FilterExpression,
|
|
1312
|
+
// applied after the read because BatchGetItem has none to push into. A row the
|
|
1313
|
+
// caller does not own is dropped rather than refused, for the reason the
|
|
1314
|
+
// single-key door answers null: distinguishing "not yours" from "not there"
|
|
1315
|
+
// would make this door an oracle for which ids exist.${switch ownerField {
|
|
1316
|
+
| None => ""
|
|
1317
|
+
| Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
|
|
1318
|
+
}}${retiredGuardPreamble(
|
|
1319
|
+
~retiredField,
|
|
1320
|
+
~retiredValues,
|
|
1321
|
+
~elevatedGroups,
|
|
1322
|
+
~ownerScoped=ownerField->Option.isSome,
|
|
1323
|
+
)}
|
|
1324
|
+
return (ctx.result?.data?.['${tableName}'] ?? []).filter(item =>
|
|
1325
|
+
item !== null${ownerField->Option.isSome ? " && _owns(item)" : ""}${retiredField->Option.isSome
|
|
1326
|
+
? " && _live(item)"
|
|
1327
|
+
: ""});
|
|
1328
|
+
}
|
|
1329
|
+
`
|
|
1330
|
+
|
|
1331
|
+
/** The reference door — `{list}Refs(ids)`: what a caller holding a pointer to a
|
|
1332
|
+
row may learn about it, and nothing else.
|
|
1333
|
+
|
|
1334
|
+
The same BatchGetItem as `batchGetItemsByIds`, projected in the response to
|
|
1335
|
+
`{id, label, retired, retiredState}`. The projection is the type's — a caller
|
|
1336
|
+
cannot ask for a price here because the SDL type has none — so the response
|
|
1337
|
+
only has to *build* the three fields, never decide which to withhold.
|
|
1338
|
+
|
|
1339
|
+
`namedWhenRetired` is what a retired row turns on: false drops it, exactly as
|
|
1340
|
+
every other door does; true lets it through with the state that withdrew it.
|
|
1341
|
+
The owner rule is applied either way and is not what the annotation lifts. */
|
|
1342
|
+
let refsByIds = (
|
|
1343
|
+
~labelField: string,
|
|
1344
|
+
~retiredField: option<string>,
|
|
1345
|
+
~retiredValues: option<array<string>>,
|
|
1346
|
+
~namedWhenRetired: bool,
|
|
1347
|
+
~ownerField: option<string>=?,
|
|
1348
|
+
~elevatedGroups: array<string>=[],
|
|
1349
|
+
) => (tableName: string) => {
|
|
1350
|
+
let ownerGuard = switch ownerField {
|
|
1351
|
+
// Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
|
|
1352
|
+
// zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
|
|
1353
|
+
// arguments, but got 1") and AppSync rejects the whole resolver at create
|
|
1354
|
+
// time with "The code contains one or more errors". Only a door that emits
|
|
1355
|
+
// the call conditionally is safe with a bare `() => true`, and that is not a
|
|
1356
|
+
// property worth relying on across three templates.
|
|
1357
|
+
| None => "\n const _owns = (row) => true;"
|
|
1358
|
+
| Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
|
|
1359
|
+
}
|
|
1360
|
+
// Retirement, in the vocabulary the row itself uses: a member test for the
|
|
1361
|
+
// state form, truthiness for the boolean one. Absent keeps the row live, which
|
|
1362
|
+
// is what a row written before the annotation is.
|
|
1363
|
+
let retiredExpr = switch (retiredField, retiredValues) {
|
|
1364
|
+
| (None, _) => "false"
|
|
1365
|
+
| (Some(f), Some(values)) =>
|
|
1366
|
+
let literal = values->Array.map(v => `'${v}'`)->Array.join(", ")
|
|
1367
|
+
`[${literal}].indexOf(row['${f}']) >= 0`
|
|
1368
|
+
| (Some(f), None) => `row['${f}'] === true`
|
|
1369
|
+
}
|
|
1370
|
+
// Only the state form has a state to name, and only a retired row reports one:
|
|
1371
|
+
// this door names rows, it does not publish a lifecycle column to callers the
|
|
1372
|
+
// list withholds.
|
|
1373
|
+
let stateExpr = switch (retiredField, retiredValues) {
|
|
1374
|
+
| (Some(f), Some(_)) => `_retired(row) ? (row['${f}'] ?? null) : null`
|
|
1375
|
+
| _ => "null"
|
|
1376
|
+
}
|
|
1377
|
+
`${importUtil}
|
|
1378
|
+
import { runtime } from '@aws-appsync/utils';
|
|
1379
|
+
export function request(ctx) {
|
|
1380
|
+
const ids = ctx.args.ids ?? [];
|
|
1381
|
+
if (ids.length === 0) return runtime.earlyReturn([]);
|
|
1382
|
+
return {
|
|
1383
|
+
operation: 'BatchGetItem',
|
|
1384
|
+
tables: {
|
|
1385
|
+
'${tableName}': {
|
|
1386
|
+
keys: ids.map(id => ({ id: util.dynamodb.toDynamoDB(id) })),
|
|
1387
|
+
consistentRead: true,
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
export function response(ctx) {
|
|
1393
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerGuard}
|
|
1394
|
+
const _retired = (row) => ${retiredExpr};
|
|
1395
|
+
const _namesRetired = ${namedWhenRetired ? "true" : "false"};
|
|
1396
|
+
return (ctx.result?.data?.['${tableName}'] ?? [])
|
|
1397
|
+
.filter(row => row !== null && _owns(row))
|
|
1398
|
+
.filter(row => _namesRetired || !_retired(row))
|
|
1399
|
+
.map(row => ({
|
|
1400
|
+
id: row.id,
|
|
1401
|
+
label: row['${labelField}'] ?? row.id,
|
|
1402
|
+
retired: _retired(row),
|
|
1403
|
+
retiredState: ${stateExpr},
|
|
1404
|
+
}));
|
|
1026
1405
|
}
|
|
1027
1406
|
`
|
|
1407
|
+
}
|
|
1028
1408
|
|
|
1029
1409
|
// ---------------------------------------------------------------------------
|
|
1030
1410
|
// DynamoDB write
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
3
4
|
|
|
4
5
|
let resultResponseCode = `
|
|
5
6
|
export function response(ctx) {
|
|
@@ -32,31 +33,112 @@ function ownerGuardPreamble(ownerField, elevatedGroups) {
|
|
|
32
33
|
const _owns = (row) => row == null || _exempt || row['` + ownerField + `'] === _sub;`;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
function exemptPreamble(elevatedGroups) {
|
|
37
|
+
let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
|
|
38
|
+
return `
|
|
39
|
+
const _id = ctx.identity;
|
|
40
|
+
const _sub = _id == null ? null : _id.sub;
|
|
41
|
+
const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
|
|
42
|
+
const _elevated = [` + elevatedLiteral + `];
|
|
43
|
+
const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, ownerScoped) {
|
|
47
|
+
if (retiredField === undefined) {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
let prelude = ownerScoped ? "" : exemptPreamble(elevatedGroups);
|
|
51
|
+
let isRetired;
|
|
52
|
+
if (retiredValues !== undefined) {
|
|
53
|
+
let literal = retiredValues.map(s => `'` + s + `'`).join(", ");
|
|
54
|
+
isRetired = `[` + literal + `].indexOf(row['` + retiredField + `']) >= 0`;
|
|
43
55
|
} else {
|
|
44
|
-
|
|
56
|
+
isRetired = `row['` + retiredField + `'] === true`;
|
|
45
57
|
}
|
|
58
|
+
return prelude + `
|
|
59
|
+
// ── retirement narrowing (generated) ──
|
|
60
|
+
const _wantsRetired = _exempt && ctx.args.includeRetired === true;
|
|
61
|
+
const _live = (row) =>
|
|
62
|
+
row == null || _wantsRetired || row['` + retiredField + `'] == null || !(` + isRetired + `);`;
|
|
46
63
|
}
|
|
47
64
|
|
|
48
|
-
function
|
|
49
|
-
if (ownerField
|
|
50
|
-
return
|
|
65
|
+
function ownerFilterClause(ownerField, elevatedGroups) {
|
|
66
|
+
if (ownerField === undefined) {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
|
|
70
|
+
return `
|
|
71
|
+
// ── owner scoping (generated) ──
|
|
72
|
+
// Not read from ctx.args: a predicate deciding what the caller may see must
|
|
73
|
+
// arrive on a channel the caller cannot name.
|
|
74
|
+
const _oid = ctx.identity;
|
|
75
|
+
const _osub = _oid == null ? null : _oid.sub;
|
|
76
|
+
const _ogroups = (_oid != null && _oid.claims != null && _oid.claims['cognito:groups']) || [];
|
|
77
|
+
const _oelevated = [` + elevatedLiteral + `];
|
|
78
|
+
if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0))) {
|
|
79
|
+
if (expression) expression += ' AND ';
|
|
80
|
+
names['#owner'] = '` + ownerField + `';
|
|
81
|
+
values[':owner'] = _osub;
|
|
82
|
+
expression += '#owner = :owner';
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function retiredFilterClause(retiredField, retiredValues, elevatedGroups) {
|
|
88
|
+
if (retiredField === undefined) {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
let assignments;
|
|
92
|
+
if (retiredValues !== undefined) {
|
|
93
|
+
let assigns = retiredValues.map((state, i) => ` values[':retiredValue` + i.toString() + `'] = '` + state + `';`).join("\n");
|
|
94
|
+
let comparisons = retiredValues.map((param, i) => `#retired <> :retiredValue` + i.toString()).join(" AND ");
|
|
95
|
+
assignments = assigns + `
|
|
96
|
+
expression += '(attribute_not_exists(#retired) OR (` + comparisons + `))';`;
|
|
97
|
+
} else {
|
|
98
|
+
assignments = ` values[':retiredFalse'] = false;
|
|
99
|
+
expression += '(attribute_not_exists(#retired) OR #retired = :retiredFalse)';`;
|
|
100
|
+
}
|
|
101
|
+
return exemptPreamble(elevatedGroups) + `
|
|
102
|
+
// ── retirement narrowing (generated) ──
|
|
103
|
+
if (!(_exempt && ctx.args.includeRetired === true)) {
|
|
104
|
+
if (expression) expression += ' AND ';
|
|
105
|
+
names['#retired'] = '` + retiredField + `';
|
|
106
|
+
` + assignments + `
|
|
107
|
+
}
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ownerScopedResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) {
|
|
112
|
+
if (ownerField === undefined && retiredField === undefined) {
|
|
113
|
+
return resultResponseCode;
|
|
114
|
+
}
|
|
115
|
+
let ownerPart = ownerField !== undefined ? `
|
|
116
|
+
// ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = (row) => true;";
|
|
117
|
+
let retiredPart = retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField));
|
|
118
|
+
return `
|
|
51
119
|
export function response(ctx) {
|
|
52
|
-
if (ctx.error) util.error(ctx.error.message, ctx.error.type)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
120
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerPart + retiredPart + `
|
|
121
|
+
return _owns(ctx.result)` + (
|
|
122
|
+
Stdlib_Option.isSome(retiredField) ? " && _live(ctx.result)" : ""
|
|
123
|
+
) + ` ? ctx.result : null;
|
|
56
124
|
}`;
|
|
57
|
-
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ownerScopedFirstResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) {
|
|
128
|
+
if (ownerField === undefined && retiredField === undefined) {
|
|
58
129
|
return firstResultResponseCode;
|
|
59
130
|
}
|
|
131
|
+
let ownerPart = ownerField !== undefined ? `
|
|
132
|
+
// ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = (row) => true;";
|
|
133
|
+
let retiredPart = retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField));
|
|
134
|
+
return `
|
|
135
|
+
export function response(ctx) {
|
|
136
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerPart + retiredPart + `
|
|
137
|
+
const _row = ctx.result.items[0] ?? null;
|
|
138
|
+
return _owns(_row)` + (
|
|
139
|
+
Stdlib_Option.isSome(retiredField) ? " && _live(_row)" : ""
|
|
140
|
+
) + ` ? _row : null;
|
|
141
|
+
}`;
|
|
60
142
|
}
|
|
61
143
|
|
|
62
144
|
let pipelinePassThrough = importUtil + `
|
|
@@ -66,18 +148,11 @@ export function request(ctx) { return {}; }
|
|
|
66
148
|
|
|
67
149
|
let nodeDecodeGlobalId = importUtil + `
|
|
68
150
|
export function request(ctx) {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
ctx.stash.typeName = decoded.substring(0, colonIdx);
|
|
75
|
-
ctx.stash.localId = decoded.substring(colonIdx + 1);
|
|
76
|
-
}
|
|
77
|
-
} catch (e) {
|
|
78
|
-
ctx.stash.typeName = null;
|
|
79
|
-
ctx.stash.localId = null;
|
|
80
|
-
}
|
|
151
|
+
const decoded = util.base64Decode(ctx.args.id);
|
|
152
|
+
const colonIdx = decoded.indexOf(':');
|
|
153
|
+
const parsed = colonIdx > 0;
|
|
154
|
+
ctx.stash.typeName = parsed ? decoded.substring(0, colonIdx) : null;
|
|
155
|
+
ctx.stash.localId = parsed ? decoded.substring(colonIdx + 1) : null;
|
|
81
156
|
return { payload: null };
|
|
82
157
|
}
|
|
83
158
|
export function response(ctx) {
|
|
@@ -102,7 +177,7 @@ export function response(ctx) {
|
|
|
102
177
|
`;
|
|
103
178
|
}
|
|
104
179
|
|
|
105
|
-
function getItemById(ownerField, elevatedGroupsOpt) {
|
|
180
|
+
function getItemById(ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
|
|
106
181
|
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
107
182
|
return importUtil + `
|
|
108
183
|
export function request(ctx) {
|
|
@@ -111,7 +186,7 @@ export function request(ctx) {
|
|
|
111
186
|
key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
|
|
112
187
|
};
|
|
113
188
|
}
|
|
114
|
-
` + ownerScopedResultResponse(ownerField, elevatedGroups) + `
|
|
189
|
+
` + ownerScopedResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) + `
|
|
115
190
|
`;
|
|
116
191
|
}
|
|
117
192
|
|
|
@@ -228,7 +303,7 @@ export function response(ctx) {
|
|
|
228
303
|
`;
|
|
229
304
|
}
|
|
230
305
|
|
|
231
|
-
function queryByIdSort(sortField, ownerField, elevatedGroupsOpt) {
|
|
306
|
+
function queryByIdSort(sortField, ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
|
|
232
307
|
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
233
308
|
return importUtil + `
|
|
234
309
|
export function request(ctx) {
|
|
@@ -244,7 +319,7 @@ export function request(ctx) {
|
|
|
244
319
|
}
|
|
245
320
|
};
|
|
246
321
|
}
|
|
247
|
-
` + ownerScopedFirstResultResponse(ownerField, elevatedGroups) + `
|
|
322
|
+
` + ownerScopedFirstResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) + `
|
|
248
323
|
`;
|
|
249
324
|
}
|
|
250
325
|
|
|
@@ -313,10 +388,46 @@ export function request(ctx) {
|
|
|
313
388
|
`;
|
|
314
389
|
}
|
|
315
390
|
|
|
316
|
-
|
|
391
|
+
let indexConnectionResponseCode = `
|
|
392
|
+
export function response(ctx) {
|
|
393
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);
|
|
394
|
+
const items = ctx.result?.items ?? [];
|
|
395
|
+
const next = ctx.result?.nextToken ?? null;
|
|
396
|
+
const edges = items.map((item, i) => ({
|
|
397
|
+
node: item,
|
|
398
|
+
cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
|
|
399
|
+
}));
|
|
400
|
+
const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
|
|
401
|
+
return {
|
|
402
|
+
edges,
|
|
403
|
+
pageInfo: {
|
|
404
|
+
hasNextPage: !!next,
|
|
405
|
+
hasPreviousPage: !!ctx.args.after,
|
|
406
|
+
startCursor: edges.length > 0 ? edges[0].cursor : boundary,
|
|
407
|
+
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}`;
|
|
411
|
+
|
|
412
|
+
let indexBackwardPagingGuard = `
|
|
413
|
+
if (args.before != null || args.last != null) {
|
|
414
|
+
util.error('Backward pagination (last/before) is not supported on by-index connections; use first/after.', 'UnsupportedPagination');
|
|
415
|
+
}`;
|
|
416
|
+
|
|
417
|
+
let indexCursorPreamble = `
|
|
418
|
+
let after = null;
|
|
419
|
+
if (args.after != null && args.after !== '') {
|
|
420
|
+
const parsed = JSON.parse(util.base64Decode(args.after));
|
|
421
|
+
after = parsed.token ?? null;
|
|
422
|
+
}`;
|
|
423
|
+
|
|
424
|
+
let indexReservedArgs = `key === 'first' || key === 'after' || key === 'last' || key === 'before' || key === 'includeRetired' || key === 'limit' || key === 'nextToken' || key === 'forward'`;
|
|
425
|
+
|
|
426
|
+
function queryByIndexFiltered(index, idField, ownerField, retiredField, retiredValues, elevatedGroupsOpt) {
|
|
427
|
+
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
317
428
|
return importUtil + `
|
|
318
429
|
export function request(ctx) {
|
|
319
|
-
const args = ctx.args
|
|
430
|
+
const args = ctx.args;` + indexBackwardPagingGuard + `
|
|
320
431
|
const query = {
|
|
321
432
|
expression: '#` + idField + ` = :` + idField + `',
|
|
322
433
|
expressionNames: { '#` + idField + `': '` + idField + `' },
|
|
@@ -327,7 +438,7 @@ export function request(ctx) {
|
|
|
327
438
|
const values = {};
|
|
328
439
|
Object.keys(args).forEach(key => {
|
|
329
440
|
const value = args[key];
|
|
330
|
-
if (key === '` + idField + `' ||
|
|
441
|
+
if (key === '` + idField + `' || ` + indexReservedArgs + `) return;
|
|
331
442
|
if (value == null || value === '') return;
|
|
332
443
|
if (expression) expression += ' AND';
|
|
333
444
|
if (key === 'hideDeleted') {
|
|
@@ -342,12 +453,13 @@ export function request(ctx) {
|
|
|
342
453
|
values[':' + key] = '' + value;
|
|
343
454
|
}
|
|
344
455
|
});
|
|
456
|
+
` + ownerFilterClause(ownerField, elevatedGroups) + retiredFilterClause(retiredField, retiredValues, elevatedGroups) + indexCursorPreamble + `
|
|
345
457
|
const result = {
|
|
346
458
|
operation: 'Query',
|
|
347
459
|
query,
|
|
348
460
|
index: '` + index + `',
|
|
349
|
-
limit: (args.
|
|
350
|
-
nextToken:
|
|
461
|
+
limit: (args.first ?? 50),
|
|
462
|
+
nextToken: after,
|
|
351
463
|
scanIndexForward: (args.forward ?? true)
|
|
352
464
|
};
|
|
353
465
|
if (expression) {
|
|
@@ -355,14 +467,15 @@ export function request(ctx) {
|
|
|
355
467
|
}
|
|
356
468
|
return result;
|
|
357
469
|
}
|
|
358
|
-
` +
|
|
470
|
+
` + indexConnectionResponseCode + `
|
|
359
471
|
`;
|
|
360
472
|
}
|
|
361
473
|
|
|
362
|
-
function queryByIndexSortFiltered(index, idField, sortField) {
|
|
474
|
+
function queryByIndexSortFiltered(index, idField, ownerField, sortField, retiredField, retiredValues, elevatedGroupsOpt) {
|
|
475
|
+
let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
|
|
363
476
|
return importUtil + `
|
|
364
477
|
export function request(ctx) {
|
|
365
|
-
const args = ctx.args
|
|
478
|
+
const args = ctx.args;` + indexBackwardPagingGuard + `
|
|
366
479
|
const query = args.` + sortField + `
|
|
367
480
|
? {
|
|
368
481
|
expression: '#` + idField + ` = :` + idField + ` AND #` + sortField + ` = :` + sortField + `',
|
|
@@ -382,7 +495,7 @@ export function request(ctx) {
|
|
|
382
495
|
const values = {};
|
|
383
496
|
Object.keys(args).forEach(key => {
|
|
384
497
|
const value = args[key];
|
|
385
|
-
if (key === '` + idField + `' || key === '` + sortField + `' ||
|
|
498
|
+
if (key === '` + idField + `' || key === '` + sortField + `' || ` + indexReservedArgs + `) return;
|
|
386
499
|
if (value == null || value === '') return;
|
|
387
500
|
if (expression) expression += ' AND';
|
|
388
501
|
if (key === 'hideDeleted') {
|
|
@@ -397,12 +510,13 @@ export function request(ctx) {
|
|
|
397
510
|
values[':' + key] = '' + value;
|
|
398
511
|
}
|
|
399
512
|
});
|
|
513
|
+
` + ownerFilterClause(ownerField, elevatedGroups) + retiredFilterClause(retiredField, retiredValues, elevatedGroups) + indexCursorPreamble + `
|
|
400
514
|
const result = {
|
|
401
515
|
operation: 'Query',
|
|
402
516
|
query,
|
|
403
517
|
index: '` + index + `',
|
|
404
|
-
limit: (args.
|
|
405
|
-
nextToken:
|
|
518
|
+
limit: (args.first ?? 50),
|
|
519
|
+
nextToken: after,
|
|
406
520
|
scanIndexForward: (args.forward ?? true)
|
|
407
521
|
};
|
|
408
522
|
if (expression) {
|
|
@@ -410,7 +524,7 @@ export function request(ctx) {
|
|
|
410
524
|
}
|
|
411
525
|
return result;
|
|
412
526
|
}
|
|
413
|
-
` +
|
|
527
|
+
` + indexConnectionResponseCode + `
|
|
414
528
|
`;
|
|
415
529
|
}
|
|
416
530
|
|
|
@@ -780,8 +894,10 @@ export function response(ctx) {
|
|
|
780
894
|
`;
|
|
781
895
|
}
|
|
782
896
|
|
|
783
|
-
function batchGetItemsByIds(
|
|
784
|
-
return
|
|
897
|
+
function batchGetItemsByIds(ownerField, retiredField, retiredValues, $staropt$star) {
|
|
898
|
+
return tableName => {
|
|
899
|
+
let elevatedGroups = $staropt$star !== undefined ? $staropt$star : [];
|
|
900
|
+
return importUtil + `
|
|
785
901
|
import { runtime } from '@aws-appsync/utils';
|
|
786
902
|
export function request(ctx) {
|
|
787
903
|
const ids = ctx.args.ids ?? [];
|
|
@@ -804,9 +920,73 @@ export function response(ctx) {
|
|
|
804
920
|
// missing id makes the entire field fail with "Cannot return null for
|
|
805
921
|
// non-nullable type" and the caller sees data=null. Filter the nulls so
|
|
806
922
|
// the field returns just the items that were found.
|
|
807
|
-
|
|
923
|
+
// The owner and retirement guards the list pushes into a FilterExpression,
|
|
924
|
+
// applied after the read because BatchGetItem has none to push into. A row the
|
|
925
|
+
// caller does not own is dropped rather than refused, for the reason the
|
|
926
|
+
// single-key door answers null: distinguishing "not yours" from "not there"
|
|
927
|
+
// would make this door an oracle for which ids exist.` + (
|
|
928
|
+
ownerField !== undefined ? ownerGuardPreamble(ownerField, elevatedGroups) : ""
|
|
929
|
+
) + retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField)) + `
|
|
930
|
+
return (ctx.result?.data?.['` + tableName + `'] ?? []).filter(item =>
|
|
931
|
+
item !== null` + (
|
|
932
|
+
Stdlib_Option.isSome(ownerField) ? " && _owns(item)" : ""
|
|
933
|
+
) + (
|
|
934
|
+
Stdlib_Option.isSome(retiredField) ? " && _live(item)" : ""
|
|
935
|
+
) + `);
|
|
808
936
|
}
|
|
809
937
|
`;
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function refsByIds(labelField, retiredField, retiredValues, namedWhenRetired, ownerField, $staropt$star) {
|
|
942
|
+
return tableName => {
|
|
943
|
+
let elevatedGroups = $staropt$star !== undefined ? $staropt$star : [];
|
|
944
|
+
let ownerGuard = ownerField !== undefined ? ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = (row) => true;";
|
|
945
|
+
let retiredExpr;
|
|
946
|
+
if (retiredField !== undefined) {
|
|
947
|
+
if (retiredValues !== undefined) {
|
|
948
|
+
let literal = retiredValues.map(v => `'` + v + `'`).join(", ");
|
|
949
|
+
retiredExpr = `[` + literal + `].indexOf(row['` + retiredField + `']) >= 0`;
|
|
950
|
+
} else {
|
|
951
|
+
retiredExpr = `row['` + retiredField + `'] === true`;
|
|
952
|
+
}
|
|
953
|
+
} else {
|
|
954
|
+
retiredExpr = "false";
|
|
955
|
+
}
|
|
956
|
+
let stateExpr = retiredField !== undefined && retiredValues !== undefined ? `_retired(row) ? (row['` + retiredField + `'] ?? null) : null` : "null";
|
|
957
|
+
return importUtil + `
|
|
958
|
+
import { runtime } from '@aws-appsync/utils';
|
|
959
|
+
export function request(ctx) {
|
|
960
|
+
const ids = ctx.args.ids ?? [];
|
|
961
|
+
if (ids.length === 0) return runtime.earlyReturn([]);
|
|
962
|
+
return {
|
|
963
|
+
operation: 'BatchGetItem',
|
|
964
|
+
tables: {
|
|
965
|
+
'` + tableName + `': {
|
|
966
|
+
keys: ids.map(id => ({ id: util.dynamodb.toDynamoDB(id) })),
|
|
967
|
+
consistentRead: true,
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
export function response(ctx) {
|
|
973
|
+
if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerGuard + `
|
|
974
|
+
const _retired = (row) => ` + retiredExpr + `;
|
|
975
|
+
const _namesRetired = ` + (
|
|
976
|
+
namedWhenRetired ? "true" : "false"
|
|
977
|
+
) + `;
|
|
978
|
+
return (ctx.result?.data?.['` + tableName + `'] ?? [])
|
|
979
|
+
.filter(row => row !== null && _owns(row))
|
|
980
|
+
.filter(row => _namesRetired || !_retired(row))
|
|
981
|
+
.map(row => ({
|
|
982
|
+
id: row.id,
|
|
983
|
+
label: row['` + labelField + `'] ?? row.id,
|
|
984
|
+
retired: _retired(row),
|
|
985
|
+
retiredState: ` + stateExpr + `,
|
|
986
|
+
}));
|
|
987
|
+
}
|
|
988
|
+
`;
|
|
989
|
+
};
|
|
810
990
|
}
|
|
811
991
|
|
|
812
992
|
let putItem = importUtil + `
|
|
@@ -1019,6 +1199,10 @@ export {
|
|
|
1019
1199
|
resultListResponseCode,
|
|
1020
1200
|
importUtil,
|
|
1021
1201
|
ownerGuardPreamble,
|
|
1202
|
+
exemptPreamble,
|
|
1203
|
+
retiredGuardPreamble,
|
|
1204
|
+
ownerFilterClause,
|
|
1205
|
+
retiredFilterClause,
|
|
1022
1206
|
ownerScopedResultResponse,
|
|
1023
1207
|
ownerScopedFirstResultResponse,
|
|
1024
1208
|
pipelinePassThrough,
|
|
@@ -1031,6 +1215,10 @@ export {
|
|
|
1031
1215
|
queryByIndex,
|
|
1032
1216
|
queryByIndexDeletable,
|
|
1033
1217
|
queryByIndexSort,
|
|
1218
|
+
indexConnectionResponseCode,
|
|
1219
|
+
indexBackwardPagingGuard,
|
|
1220
|
+
indexCursorPreamble,
|
|
1221
|
+
indexReservedArgs,
|
|
1034
1222
|
queryByIndexFiltered,
|
|
1035
1223
|
queryByIndexSortFiltered,
|
|
1036
1224
|
listAllItems,
|
|
@@ -1043,6 +1231,7 @@ export {
|
|
|
1043
1231
|
resolveIdByIndexSortArgument,
|
|
1044
1232
|
resolveIds,
|
|
1045
1233
|
batchGetItemsByIds,
|
|
1234
|
+
refsByIds,
|
|
1046
1235
|
putItem,
|
|
1047
1236
|
addItemToList,
|
|
1048
1237
|
deleteItem,
|
|
@@ -23,7 +23,7 @@ describe('Resolver code structure', () => {
|
|
|
23
23
|
['queryByIndexDeletable(index)', F.queryByIndexDeletable('userId')],
|
|
24
24
|
['queryByIndexSort(index,idField,sortField)', F.queryByIndexSort('userId', 'userId', 'createdAt')],
|
|
25
25
|
['queryByIndexFiltered(index,idField)', F.queryByIndexFiltered('userId', 'userId')],
|
|
26
|
-
['queryByIndexSortFiltered(index,idField,sortField)', F.queryByIndexSortFiltered('userId', 'userId', 'status')],
|
|
26
|
+
['queryByIndexSortFiltered(index,idField,sortField)', F.queryByIndexSortFiltered('userId', 'userId', undefined, 'status')],
|
|
27
27
|
['listAllItems', F.listAllItems],
|
|
28
28
|
['resolveId(sourceIdField)', F.resolveId('productId')],
|
|
29
29
|
['resolveIdSort(sourceIdField,sourceSortField,targetSortField)', F.resolveIdSort('productId', 'status', 'status')],
|
|
@@ -437,7 +437,12 @@ describe('listAllItemsConnection', () => {
|
|
|
437
437
|
// queryByIndexSortFiltered — complex template, most important to test
|
|
438
438
|
// ---------------------------------------------------------------------------
|
|
439
439
|
describe('queryByIndexSortFiltered', () => {
|
|
440
|
-
|
|
440
|
+
// index, idField, ownerField, sortField — `ownerField` is optional but sits
|
|
441
|
+
// ahead of `sortField`, so it has to be passed explicitly. Skipping it slid
|
|
442
|
+
// the sort key into the owner slot and this file scoped the read by `status`
|
|
443
|
+
// while asserting it did not, which is the failure mode a positional call
|
|
444
|
+
// against a labelled-argument function always has.
|
|
445
|
+
const code = F.queryByIndexSortFiltered('ownerId', 'ownerId', undefined, 'status')
|
|
441
446
|
const { request, response } = evalResolver(code)
|
|
442
447
|
|
|
443
448
|
test('query by idField only when sortField is absent', () => {
|
|
@@ -483,9 +488,19 @@ describe('queryByIndexSortFiltered', () => {
|
|
|
483
488
|
expect(request(ctx).scanIndexForward).toBe(true)
|
|
484
489
|
})
|
|
485
490
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
491
|
+
// The field this template is attached to has always been declared as returning
|
|
492
|
+
// a Connection; handing back DynamoDB's `{items, nextToken}` satisfied no part
|
|
493
|
+
// of that, so the door errored for every caller that selected `edges`.
|
|
494
|
+
test('response returns a Relay connection', () => {
|
|
495
|
+
const ctx = makeCtx({ result: { items: [{ id: 'x' }], nextToken: null } })
|
|
496
|
+
const out = response(ctx)
|
|
497
|
+
expect(out.edges.map(e => e.node)).toEqual([{ id: 'x' }])
|
|
498
|
+
expect(out.pageInfo.hasNextPage).toBe(false)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('reports a further page when DynamoDB hands back a token', () => {
|
|
502
|
+
const ctx = makeCtx({ result: { items: [{ id: 'x' }], nextToken: 'TOK' } })
|
|
503
|
+
expect(response(ctx).pageInfo.hasNextPage).toBe(true)
|
|
489
504
|
})
|
|
490
505
|
})
|
|
491
506
|
|