@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.4 → 3.0.0-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +300 -0
  2. package/package.json +8 -7
  3. package/rescript.json +2 -1
  4. package/src/AppSync/AppSync_ResolverRuntime.res +4 -5
  5. package/src/AppSync/AppSync_Resolver_Functions.res +281 -169
  6. package/src/AppSync/AppSync_Resolver_Functions.res.mjs +116 -72
  7. package/src/AppSync/AppSync_SourceApiAssociation.res +1 -3
  8. package/src/AwsNative/AppSync/AwsNative_AppSync_Api.res +2 -3
  9. package/src/AwsNative/AppSync/AwsNative_AppSync_ChannelNamespace.res +2 -6
  10. package/src/AwsNative/AppSync/AwsNative_AppSync_Resolver.res +2 -5
  11. package/src/Cloudwatch/Cloudwatch_EventRule.res +4 -0
  12. package/src/Cloudwatch/Cloudwatch_EventTarget.res +11 -0
  13. package/src/Cloudwatch/Cloudwatch_LogMetricFilter.res +0 -1
  14. package/src/Cloudwatch/Cloudwatch_MetricAlarm.res +1 -2
  15. package/src/Cognito/Cognito_UserPool.res +2 -4
  16. package/src/IAM/IAM.res +1 -1
  17. package/src/IAM/PolicyDocument.res +2 -2
  18. package/src/IAM/PolicyDocument.res.mjs +3 -3
  19. package/src/Kinesis/Kinesis_Stream.res +3 -6
  20. package/src/Kms/Kms.res +5 -0
  21. package/src/Kms/Kms.res.mjs +12 -0
  22. package/src/Kms/Kms_Alias.res +16 -0
  23. package/src/Kms/Kms_Alias.res.mjs +2 -0
  24. package/src/Kms/Kms_Key.res +23 -0
  25. package/src/Kms/Kms_Key.res.mjs +2 -0
  26. package/src/Lambda/FunctionUrl.res +5 -2
  27. package/src/Lambda/Lambda.res +139 -53
  28. package/src/Lambda/Lambda.res.mjs +152 -23
  29. package/src/Rds/Rds_Cluster.res +0 -1
  30. package/src/Rds/Rds_Instance.res +0 -1
  31. package/src/Rds/Rds_Proxy.res +1 -2
  32. package/src/S3/S3_Bucket.res +1 -1
  33. package/src/S3/S3_BucketV2.res +1 -3
  34. package/src/SNS/SNS_Topic.res +9 -9
  35. package/src/Scheduler/Scheduler.res +4 -0
  36. package/src/Scheduler/Scheduler.res.mjs +9 -0
  37. package/src/Scheduler/Scheduler_ScheduleGroup.res +14 -0
  38. package/src/Scheduler/Scheduler_ScheduleGroup.res.mjs +2 -0
  39. package/src/SecretsManager/GetSecretVersion.res +1 -1
  40. package/src/example/AlarmMailExample.res +34 -0
  41. package/src/example/AlarmMailExample.res.mjs +44 -0
  42. package/src/example/CustomDomainExample.res +2 -3
  43. package/src/example/MergedApiExample.res +1 -3
  44. package/tests/AppSync_Resolver_FunctionsTest.mjs +141 -23
@@ -8,9 +8,7 @@
8
8
  let mergedExecRole = IAM.Role.make(
9
9
  ~name="example-merged-exec-role",
10
10
  ~args={
11
- assumeRolePolicy: Pulumi.Input.make(
12
- `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"appsync.amazonaws.com"},"Action":"sts:AssumeRole"}]}`,
13
- ),
11
+ assumeRolePolicy: Pulumi.Input.make(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"appsync.amazonaws.com"},"Action":"sts:AssumeRole"}]}`),
14
12
  },
15
13
  )
16
14
 
@@ -158,7 +158,7 @@ describe('listAllItemsConnection', () => {
158
158
  const ctx = makeCtx({ args: {} })
159
159
  const result = request(ctx)
160
160
  expect(result.operation).toBe('Scan')
161
- expect(result.limit).toBe(50)
161
+ expect(result.limit).toBe(51)
162
162
  expect(result.nextToken).toBeNull()
163
163
  expect(result.filter).toBeUndefined()
164
164
  })
@@ -209,6 +209,11 @@ describe('listAllItemsConnection', () => {
209
209
  expect(req2.nextToken).toBe('TOK1')
210
210
  })
211
211
 
212
+ // `hasPreviousPage` reports what this door can SERVE, not what exists. This
213
+ // cursor opens a new window at position 0, so the page before it lies in the
214
+ // previous window — real, and unreachable, because a continuation token
215
+ // cannot name the one before it. Claiming it is what drew a Prev button that
216
+ // always errored.
212
217
  test('final page (nextToken null) closes the connection', () => {
213
218
  const after = util.base64Encode(JSON.stringify({ t: 'TOK1', n: -1 }))
214
219
  const r = response(makeCtx({
@@ -216,7 +221,7 @@ describe('listAllItemsConnection', () => {
216
221
  result: { items: [{ id: 'z' }], nextToken: null },
217
222
  }))
218
223
  expect(r.pageInfo.hasNextPage).toBe(false)
219
- expect(r.pageInfo.hasPreviousPage).toBe(true)
224
+ expect(r.pageInfo.hasPreviousPage).toBe(false)
220
225
  })
221
226
 
222
227
  // A filtered Scan can return an empty page while nextToken is still set. The
@@ -239,15 +244,55 @@ describe('listAllItemsConnection', () => {
239
244
  expect(r.pageInfo.hasNextPage).toBe(false)
240
245
  })
241
246
 
242
- // Scan cannot page backward; last/before must error, not mislead.
243
- test('request rejects backward pagination (before)', () => {
244
- expect(() => request(makeCtx({ args: { before: 'x' } }))).toThrow(
245
- 'Backward pagination',
247
+ // `before` is served by re-reading the window the cursor names and cutting
248
+ // the page that ends at it. `last` is not: the last N rows of the list needs
249
+ // the END of the list, which a forward-only cursor cannot reach.
250
+ test('request rejects `last`, which needs the end of the list', () => {
251
+ expect(() => request(makeCtx({ args: { last: 5 } }))).toThrow('last is not supported')
252
+ })
253
+
254
+ test('a backward page is the page that precedes it, exactly', () => {
255
+ const items = Array.from({ length: 25 }, (_, i) => ({ id: 'r' + i }))
256
+ const page1 = response(makeCtx({ args: { first: 10 }, result: { items, nextToken: null } }))
257
+ const fwd = { args: { first: 10, after: page1.pageInfo.endCursor } }
258
+ const page2 = response(makeCtx({ ...fwd, result: { items, nextToken: null } }))
259
+ expect(page2.edges.map(e => e.node.id)).toEqual(items.slice(10, 20).map(i => i.id))
260
+
261
+ const back = { args: { first: 10, before: page2.pageInfo.startCursor } }
262
+ const prev = response(makeCtx({ ...back, result: { items, nextToken: null } }))
263
+ expect(prev.edges.map(e => e.node.id)).toEqual(page1.edges.map(e => e.node.id))
264
+ // Cut from ahead, so a next page provably exists — the one we came from.
265
+ expect(prev.pageInfo.hasNextPage).toBe(true)
266
+ expect(prev.pageInfo.hasPreviousPage).toBe(false)
267
+ })
268
+
269
+ // The read must reach `_upTo` rows into the window, not `first + from`, or the
270
+ // backward slice comes back with its tail missing.
271
+ test('a backward read examines the window as deep as the page it cuts', () => {
272
+ const before = util.base64Encode(JSON.stringify({ t: null, n: 30 }))
273
+ expect(request(makeCtx({ args: { first: 10, before } })).limit).toBe(30)
274
+ })
275
+
276
+ test('the window a backward cursor names is the one re-read', () => {
277
+ const before = util.base64Encode(JSON.stringify({ t: 'W7', n: 30 }))
278
+ expect(request(makeCtx({ args: { first: 10, before } })).nextToken).toBe('W7')
279
+ })
280
+
281
+ // The one page still out of reach, and the only case left refused.
282
+ test('a previous page in an earlier window is refused, and says which limit', () => {
283
+ const before = util.base64Encode(JSON.stringify({ t: 'W2', n: 0 }))
284
+ expect(() => request(makeCtx({ args: { first: 10, before } }))).toThrow(
285
+ 'earlier read window',
246
286
  )
247
287
  })
248
288
 
249
- test('request rejects backward pagination (last)', () => {
250
- expect(() => request(makeCtx({ args: { last: 5 } }))).toThrow('Backward pagination')
289
+ // At the very start there is no earlier window to be unable to name, so a
290
+ // short backward page is served rather than refused.
291
+ test('a backward page at the start of the first window is served, not refused', () => {
292
+ const items = Array.from({ length: 10 }, (_, i) => ({ id: 'r' + i }))
293
+ const before = util.base64Encode(JSON.stringify({ t: null, n: 3 }))
294
+ const r = response(makeCtx({ args: { first: 10, before }, result: { items, nextToken: null } }))
295
+ expect(r.edges.map(e => e.node.id)).toEqual(['r0', 'r1', 'r2'])
251
296
  })
252
297
 
253
298
  // Cursors minted before the read window existed named the following window.
@@ -272,10 +317,11 @@ describe('listAllItemsConnection', () => {
272
317
  F.listAllItemsConnection('name', [], [], [], undefined, 'customerId', ['Admin']),
273
318
  )
274
319
 
275
- test('an unfiltered read examines exactly the page it serves', () => {
320
+ // One row past the page: it is what says a next page exists.
321
+ test('an unfiltered read examines one row past the page it serves', () => {
276
322
  const req = request(makeCtx({ args: { first: 25 }, identity: null }))
277
323
  expect(req.filter).toBeUndefined()
278
- expect(req.limit).toBe(25)
324
+ expect(req.limit).toBe(26)
279
325
  })
280
326
 
281
327
  test('a filtered read examines a wider window than the page', () => {
@@ -285,7 +331,7 @@ describe('listAllItemsConnection', () => {
285
331
  })
286
332
 
287
333
  test('a caller asking for more than the window gets what it asked for', () => {
288
- expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5000)
334
+ expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5001)
289
335
  })
290
336
 
291
337
  // The user-visible bug: one matching row must be one page, not three.
@@ -328,6 +374,34 @@ describe('listAllItemsConnection', () => {
328
374
  ).toBe('W2')
329
375
  })
330
376
 
377
+ // DynamoDB hands back a token whenever a read stops AT its limit, whether or
378
+ // not anything follows. A read of exactly the page therefore claimed a next
379
+ // page after the last full one, and it came back blank — found on a deployed
380
+ // list paged one row at a time. The mock pages a table the way DynamoDB does.
381
+ test('paging to the end serves no blank page, whatever the page size', () => {
382
+ const rows = ['a', 'b', 'c', 'd'].map(id => ({ id }))
383
+ const dynamo = req => {
384
+ const start = req.nextToken === null ? 0 : +req.nextToken
385
+ const items = rows.slice(start, start + req.limit)
386
+ const stoppedAtLimit = items.length === req.limit
387
+ return { items, nextToken: stoppedAtLimit ? String(start + req.limit) : null }
388
+ }
389
+ ;[1, 2, 4].map(first => {
390
+ const pages = []
391
+ let after
392
+ for (let i = 0; i < 10; i++) {
393
+ const args = after === undefined ? { first } : { first, after }
394
+ const r = response(makeCtx({ args, result: dynamo(request(makeCtx({ args, identity: null }))) }))
395
+ pages.push(r.edges.map(e => e.node.id))
396
+ if (!r.pageInfo.hasNextPage) break
397
+ after = r.pageInfo.endCursor
398
+ }
399
+ expect(pages.flat()).toEqual(['a', 'b', 'c', 'd'])
400
+ expect(pages.every(p => p.length > 0)).toBe(true)
401
+ expect(pages).toHaveLength(Math.ceil(4 / first))
402
+ })
403
+ })
404
+
331
405
  // Without this the final row of a window resumes into a window with nothing
332
406
  // left in it, which is the blank page again one step later.
333
407
  test('the last row of a page that closes its window names the next window', () => {
@@ -542,7 +616,7 @@ describe('queryByIndexSortFiltered', () => {
542
616
  expect(result.query.expression).toBe('#ownerId = :ownerId')
543
617
  expect(result.filter).toBeUndefined()
544
618
  expect(result.index).toBe('ownerId')
545
- expect(result.limit).toBe(50)
619
+ expect(result.limit).toBe(51)
546
620
  })
547
621
 
548
622
  test('query includes sortField when present', () => {
@@ -655,7 +729,8 @@ describe('invokeCommandGenerator', () => {
655
729
  expect(payload.identity.userArn).toBe('arn:aws:iam::123:role/SyncRole')
656
730
  expect(payload.identity.accountId).toBe('123')
657
731
  expect(payload.identity.username).toBe('AROAEXAMPLE:caller')
658
- expect(payload.meta.ip).toEqual(['10.0.0.1'])
732
+ // The caller's address is personal data and never reaches the command.
733
+ expect(payload.meta).not.toHaveProperty('ip')
659
734
  expect(payload.meta.user).toBe('AROAEXAMPLE:caller')
660
735
  })
661
736
 
@@ -663,7 +738,7 @@ describe('invokeCommandGenerator', () => {
663
738
  const ctx = makeCtx({ identity: null })
664
739
  const { payload } = request(ctx)
665
740
  expect(payload.identity).toBeNull()
666
- expect(payload.meta.ip).toBeNull()
741
+ expect(payload.meta).not.toHaveProperty('ip')
667
742
  expect(payload.meta.user).toBeNull()
668
743
  expect(payload.command).toBe('CreateProduct')
669
744
  })
@@ -712,6 +787,28 @@ describe('invokeInboundTranslation', () => {
712
787
  expect(result.payload.fieldName).toBe('importProduct')
713
788
  expect(result.payload.arguments).toEqual({ csv: 'data' })
714
789
  })
790
+
791
+ // The Lambda checks each translated command against the caller's groups.
792
+ test('request carries the Cognito caller and its groups', () => {
793
+ const ctx = makeCtx({
794
+ identity: { username: 'alice', sub: 's1', sourceIp: [], claims: { 'cognito:groups': ['Merchandiser'] } },
795
+ })
796
+ const { payload } = request(ctx)
797
+ expect(payload.identity.userId).toBe('s1')
798
+ expect(payload.identity.groups).toEqual(['Merchandiser'])
799
+ expect(payload.identity.provider).toBe('Cognito')
800
+ })
801
+
802
+ test('request carries an IAM caller as IAM', () => {
803
+ const ctx = makeCtx({ identity: { userArn: 'arn:aws:iam::123:role/Sync', accountId: '123' } })
804
+ const { payload } = request(ctx)
805
+ expect(payload.identity.provider).toBe('IAM')
806
+ })
807
+
808
+ test('request emits null identity when ctx.identity is null', () => {
809
+ const { payload } = request(makeCtx({ identity: null }))
810
+ expect(payload.identity).toBeNull()
811
+ })
715
812
  })
716
813
 
717
814
  // ---------------------------------------------------------------------------
@@ -907,11 +1004,11 @@ describe('listAllItemsConnection — owner scoping', () => {
907
1004
 
908
1005
  // The read window exists because a FilterExpression cuts rows AFTER `Limit`.
909
1006
  // A key condition does not, so a scoped caller with no filter of their own
910
- // examines exactly the page they asked for — the arithmetic the window was
911
- // hiding, gone rather than papered over.
912
- test('a scoped caller with no filter examines exactly the page', () => {
1007
+ // examines the page they asked for and the one row that says whether another
1008
+ // follows — the arithmetic the window was hiding, gone rather than papered over.
1009
+ test('a scoped caller with no filter examines only the page, plus one', () => {
913
1010
  const { request } = indexed()
914
- expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(25)
1011
+ expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(26)
915
1012
  })
916
1013
 
917
1014
  test("a scoped caller's own filter still widens the window", () => {
@@ -1025,16 +1122,37 @@ describe('listAllItemsConnection — owner scoping', () => {
1025
1122
  })
1026
1123
  })
1027
1124
 
1028
- // Backward paging stays refused on both branches: a Query can walk backward,
1029
- // but the `{t, n}` cursor is DynamoDB's forward-only continuation token, and a
1030
- // door must not advertise what its cursor cannot honour.
1125
+ // Backward paging works the same way on both branches, and for the same reason:
1126
+ // it re-reads the window the cursor names and cuts the page ending at it, which
1127
+ // is operation-agnostic. `last` stays refused on both — it needs the end of the
1128
+ // list, which neither a Scan nor this Query's cursor can reach.
1031
1129
  test.each([['scoped', asUser('cust-a')], ['exempt', asUser('ops-1', ['Admin'])]])(
1032
- 'a %s caller is still refused backward paging',
1130
+ 'a %s caller is still refused `last`',
1033
1131
  (_, identity) => {
1034
1132
  const { request } = indexed()
1035
- expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow('Backward pagination')
1133
+ expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow(
1134
+ 'last is not supported',
1135
+ )
1036
1136
  },
1037
1137
  )
1138
+
1139
+ // The path tag has to survive the direction change: a backward cursor still
1140
+ // names the read that minted it, and replaying it on the other branch is the
1141
+ // same mistake as replaying a forward one.
1142
+ test('a backward cursor is path-checked like a forward one', () => {
1143
+ const { request, response } = indexed()
1144
+ const scopedPage = response(makeCtx({
1145
+ args: { first: 10 },
1146
+ identity: asUser('cust-a'),
1147
+ result: { items: Array.from({ length: 10 }, (_, i) => ({ id: 'r' + i })), nextToken: null },
1148
+ }))
1149
+ expect(() =>
1150
+ request(makeCtx({
1151
+ args: { first: 10, before: scopedPage.pageInfo.endCursor },
1152
+ identity: asUser('ops-1', ['Admin']),
1153
+ })),
1154
+ ).toThrow('different read of this list')
1155
+ })
1038
1156
  })
1039
1157
 
1040
1158
  // ---------------------------------------------------------------------------