@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.25 → 3.0.0-alpha.27

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,20 @@
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
+ # 3.0.0-alpha.27 (2026-10-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **pulumi-aws:** a list's last page no longer promises another ([00b141f](https://github.com/ReventlessDev/reventless-core/commit/00b141fbfdbf44544fbea8ab979356831381f48a))
11
+
12
+
13
+ # 3.0.0-alpha.26 (2026-10-10)
14
+
15
+ ### Bug Fixes
16
+
17
+ * an inbound translation is gated alike on every door, and encodes its commands by schema ([6ec3ce6](https://github.com/ReventlessDev/reventless-core/commit/6ec3ce672e53e2c162c64a90d103be691391be5e))
18
+
19
+
6
20
  # 3.0.0-alpha.25 (2026-10-08)
7
21
 
8
22
  **Note:** Version bump only for package @reventlessdev/rescript-pulumi-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/rescript-pulumi-aws",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.27",
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",
25
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.30",
26
25
  "@reventlessdev/rescript-node": "2.0.0-alpha.22",
26
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.30",
27
27
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.23"
28
28
  },
29
29
  "devDependencies": {
@@ -564,19 +564,24 @@ the survivors serves short — usually empty — pages under a selective filter.
564
564
  no loops in APPSYNC_JS the door reads wider instead and addresses the surplus by
565
565
  position. 1 MB caps a page anyway, hence 1000; `filtered` is the JS expression
566
566
  saying whether a filter was pushed down.
567
+
568
+ An unfiltered read takes one row past the page. DynamoDB hands back a token
569
+ whenever it stops AT the limit, end of data or not, so a read of exactly the page
570
+ reports a next page that comes back blank; the extra row is what says one exists.
567
571
  */
568
572
  let pageWindowBudget = (~filtered: string) =>
569
- `(${filtered} ? (_first > 1000 ? _first : 1000) : _first + _from)`
573
+ `(${filtered} ? (_first > 1000 ? _first : 1000) : _first + _from + 1)`
570
574
 
571
575
  /**
572
576
  The same budget for the full-list door, which may also read backward.
573
577
 
574
578
  A backward page is the slice `[_from, _upTo)` of the window, so the read has to
575
579
  reach `_upTo` rows into it — `_first + _from` would stop short and hand back a
576
- page with its tail missing.
580
+ page with its tail missing. Forward reads one row past the page, for the reason
581
+ `pageWindowBudget` gives; a backward page needs none, as one provably follows it.
577
582
  */
578
583
  let listPageWindowBudget = {
579
- let need = "(_backward ? _upTo : _first + _from)"
584
+ let need = "(_backward ? _upTo : _first + _from + 1)"
580
585
  `(parts.length > 0 ? (${need} > 1000 ? ${need} : 1000) : ${need})`
581
586
  }
582
587
 
@@ -1671,6 +1676,24 @@ ${resultResponseCode}
1671
1676
  // Lambda invocations
1672
1677
  // ---------------------------------------------------------------------------
1673
1678
 
1679
+ // The caller as a command Lambda reads it: a Cognito user, an IAM principal, or null.
1680
+ let callerIdentity = `identity: isCognito
1681
+ ? {
1682
+ userId: id.sub,
1683
+ username: id.username,
1684
+ groups: id.claims?.['cognito:groups'] ?? [],
1685
+ claims: id.claims,
1686
+ provider: 'Cognito'
1687
+ }
1688
+ : id != null
1689
+ ? {
1690
+ userArn: id.userArn ?? null,
1691
+ accountId: id.accountId ?? null,
1692
+ username: id.username ?? null,
1693
+ provider: 'IAM'
1694
+ }
1695
+ : null`
1696
+
1674
1697
  /** Invoke a CommandGenerator Lambda. `command` is the Aggregate command name. */
1675
1698
  let invokeCommandGenerator = (command: string) =>
1676
1699
  `${importUtil}
@@ -1687,22 +1710,7 @@ export function request(ctx) {
1687
1710
  user: id?.username ?? null,
1688
1711
  info: ctx.info.parentTypeName + '.' + ctx.info.fieldName
1689
1712
  },
1690
- identity: isCognito
1691
- ? {
1692
- userId: id.sub,
1693
- username: id.username,
1694
- groups: id.claims?.['cognito:groups'] ?? [],
1695
- claims: id.claims,
1696
- provider: 'Cognito'
1697
- }
1698
- : id != null
1699
- ? {
1700
- userArn: id.userArn ?? null,
1701
- accountId: id.accountId ?? null,
1702
- username: id.username ?? null,
1703
- provider: 'IAM'
1704
- }
1705
- : null
1713
+ ${callerIdentity}
1706
1714
  }
1707
1715
  };
1708
1716
  }
@@ -1725,22 +1733,7 @@ export function request(ctx) {
1725
1733
  user: id?.username ?? null,
1726
1734
  info: ctx.info.parentTypeName + '.' + ctx.info.fieldName
1727
1735
  },
1728
- identity: isCognito
1729
- ? {
1730
- userId: id.sub,
1731
- username: id.username,
1732
- groups: id.claims?.['cognito:groups'] ?? [],
1733
- claims: id.claims,
1734
- provider: 'Cognito'
1735
- }
1736
- : id != null
1737
- ? {
1738
- userArn: id.userArn ?? null,
1739
- accountId: id.accountId ?? null,
1740
- username: id.username ?? null,
1741
- provider: 'IAM'
1742
- }
1743
- : null
1736
+ ${callerIdentity}
1744
1737
  }
1745
1738
  };
1746
1739
  }
@@ -1751,12 +1744,15 @@ ${resultResponseCode}
1751
1744
  let invokeInboundTranslation = (fieldName: string) =>
1752
1745
  `${importUtil}
1753
1746
  export function request(ctx) {
1747
+ const id = ctx.identity;
1748
+ const isCognito = id != null && id.sub != null;
1754
1749
  return {
1755
1750
  operation: 'Invoke',
1756
1751
  payload: {
1757
1752
  __inboundTranslation: true,
1758
1753
  fieldName: '${fieldName}',
1759
- arguments: ctx.args
1754
+ arguments: ctx.args,
1755
+ ${callerIdentity}
1760
1756
  }
1761
1757
  };
1762
1758
  }
@@ -389,10 +389,10 @@ export function request(ctx) {
389
389
  }
390
390
 
391
391
  function pageWindowBudget(filtered) {
392
- return `(` + filtered + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`;
392
+ return `(` + filtered + ` ? (_first > 1000 ? _first : 1000) : _first + _from + 1)`;
393
393
  }
394
394
 
395
- let need = "(_backward ? _upTo : _first + _from)";
395
+ let need = "(_backward ? _upTo : _first + _from + 1)";
396
396
 
397
397
  let listPageWindowBudget = `(parts.length > 0 ? (` + need + ` > 1000 ? ` + need + ` : 1000) : ` + need + `)`;
398
398
 
@@ -528,7 +528,7 @@ export function request(ctx) {
528
528
  operation: 'Query',
529
529
  query,
530
530
  index: '` + index + `',
531
- limit: ` + (`(` + "expression" + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`) + `,
531
+ limit: ` + (`(` + "expression" + ` ? (_first > 1000 ? _first : 1000) : _first + _from + 1)`) + `,
532
532
  nextToken: _window,
533
533
  scanIndexForward: (args.forward ?? true)
534
534
  };
@@ -585,7 +585,7 @@ export function request(ctx) {
585
585
  operation: 'Query',
586
586
  query,
587
587
  index: '` + index + `',
588
- limit: ` + (`(` + "expression" + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`) + `,
588
+ limit: ` + (`(` + "expression" + ` ? (_first > 1000 ? _first : 1000) : _first + _from + 1)`) + `,
589
589
  nextToken: _window,
590
590
  scanIndexForward: (args.forward ?? true)
591
591
  };
@@ -1140,6 +1140,23 @@ export function request(ctx) {
1140
1140
  ` + resultResponseCode + `
1141
1141
  `;
1142
1142
 
1143
+ let callerIdentity = `identity: isCognito
1144
+ ? {
1145
+ userId: id.sub,
1146
+ username: id.username,
1147
+ groups: id.claims?.['cognito:groups'] ?? [],
1148
+ claims: id.claims,
1149
+ provider: 'Cognito'
1150
+ }
1151
+ : id != null
1152
+ ? {
1153
+ userArn: id.userArn ?? null,
1154
+ accountId: id.accountId ?? null,
1155
+ username: id.username ?? null,
1156
+ provider: 'IAM'
1157
+ }
1158
+ : null`;
1159
+
1143
1160
  function invokeCommandGenerator(command) {
1144
1161
  return importUtil + `
1145
1162
  export function request(ctx) {
@@ -1155,22 +1172,7 @@ export function request(ctx) {
1155
1172
  user: id?.username ?? null,
1156
1173
  info: ctx.info.parentTypeName + '.' + ctx.info.fieldName
1157
1174
  },
1158
- identity: isCognito
1159
- ? {
1160
- userId: id.sub,
1161
- username: id.username,
1162
- groups: id.claims?.['cognito:groups'] ?? [],
1163
- claims: id.claims,
1164
- provider: 'Cognito'
1165
- }
1166
- : id != null
1167
- ? {
1168
- userArn: id.userArn ?? null,
1169
- accountId: id.accountId ?? null,
1170
- username: id.username ?? null,
1171
- provider: 'IAM'
1172
- }
1173
- : null
1175
+ ` + callerIdentity + `
1174
1176
  }
1175
1177
  };
1176
1178
  }
@@ -1193,22 +1195,7 @@ export function request(ctx) {
1193
1195
  user: id?.username ?? null,
1194
1196
  info: ctx.info.parentTypeName + '.' + ctx.info.fieldName
1195
1197
  },
1196
- identity: isCognito
1197
- ? {
1198
- userId: id.sub,
1199
- username: id.username,
1200
- groups: id.claims?.['cognito:groups'] ?? [],
1201
- claims: id.claims,
1202
- provider: 'Cognito'
1203
- }
1204
- : id != null
1205
- ? {
1206
- userArn: id.userArn ?? null,
1207
- accountId: id.accountId ?? null,
1208
- username: id.username ?? null,
1209
- provider: 'IAM'
1210
- }
1211
- : null
1198
+ ` + callerIdentity + `
1212
1199
  }
1213
1200
  };
1214
1201
  }
@@ -1219,12 +1206,15 @@ export function request(ctx) {
1219
1206
  function invokeInboundTranslation(fieldName) {
1220
1207
  return importUtil + `
1221
1208
  export function request(ctx) {
1209
+ const id = ctx.identity;
1210
+ const isCognito = id != null && id.sub != null;
1222
1211
  return {
1223
1212
  operation: 'Invoke',
1224
1213
  payload: {
1225
1214
  __inboundTranslation: true,
1226
1215
  fieldName: '` + fieldName + `',
1227
- arguments: ctx.args
1216
+ arguments: ctx.args,
1217
+ ` + callerIdentity + `
1228
1218
  }
1229
1219
  };
1230
1220
  }
@@ -1355,6 +1345,7 @@ export {
1355
1345
  putItem,
1356
1346
  addItemToList,
1357
1347
  deleteItem,
1348
+ callerIdentity,
1358
1349
  invokeCommandGenerator,
1359
1350
  invokeDcbMutation,
1360
1351
  invokeInboundTranslation,
@@ -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
  })
@@ -317,10 +317,11 @@ describe('listAllItemsConnection', () => {
317
317
  F.listAllItemsConnection('name', [], [], [], undefined, 'customerId', ['Admin']),
318
318
  )
319
319
 
320
- 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', () => {
321
322
  const req = request(makeCtx({ args: { first: 25 }, identity: null }))
322
323
  expect(req.filter).toBeUndefined()
323
- expect(req.limit).toBe(25)
324
+ expect(req.limit).toBe(26)
324
325
  })
325
326
 
326
327
  test('a filtered read examines a wider window than the page', () => {
@@ -330,7 +331,7 @@ describe('listAllItemsConnection', () => {
330
331
  })
331
332
 
332
333
  test('a caller asking for more than the window gets what it asked for', () => {
333
- expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5000)
334
+ expect(request(makeCtx({ args: { first: 5000 } })).limit).toBe(5001)
334
335
  })
335
336
 
336
337
  // The user-visible bug: one matching row must be one page, not three.
@@ -373,6 +374,34 @@ describe('listAllItemsConnection', () => {
373
374
  ).toBe('W2')
374
375
  })
375
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
+
376
405
  // Without this the final row of a window resumes into a window with nothing
377
406
  // left in it, which is the blank page again one step later.
378
407
  test('the last row of a page that closes its window names the next window', () => {
@@ -587,7 +616,7 @@ describe('queryByIndexSortFiltered', () => {
587
616
  expect(result.query.expression).toBe('#ownerId = :ownerId')
588
617
  expect(result.filter).toBeUndefined()
589
618
  expect(result.index).toBe('ownerId')
590
- expect(result.limit).toBe(50)
619
+ expect(result.limit).toBe(51)
591
620
  })
592
621
 
593
622
  test('query includes sortField when present', () => {
@@ -757,6 +786,28 @@ describe('invokeInboundTranslation', () => {
757
786
  expect(result.payload.fieldName).toBe('importProduct')
758
787
  expect(result.payload.arguments).toEqual({ csv: 'data' })
759
788
  })
789
+
790
+ // The Lambda checks each translated command against the caller's groups.
791
+ test('request carries the Cognito caller and its groups', () => {
792
+ const ctx = makeCtx({
793
+ identity: { username: 'alice', sub: 's1', sourceIp: [], claims: { 'cognito:groups': ['Merchandiser'] } },
794
+ })
795
+ const { payload } = request(ctx)
796
+ expect(payload.identity.userId).toBe('s1')
797
+ expect(payload.identity.groups).toEqual(['Merchandiser'])
798
+ expect(payload.identity.provider).toBe('Cognito')
799
+ })
800
+
801
+ test('request carries an IAM caller as IAM', () => {
802
+ const ctx = makeCtx({ identity: { userArn: 'arn:aws:iam::123:role/Sync', accountId: '123' } })
803
+ const { payload } = request(ctx)
804
+ expect(payload.identity.provider).toBe('IAM')
805
+ })
806
+
807
+ test('request emits null identity when ctx.identity is null', () => {
808
+ const { payload } = request(makeCtx({ identity: null }))
809
+ expect(payload.identity).toBeNull()
810
+ })
760
811
  })
761
812
 
762
813
  // ---------------------------------------------------------------------------
@@ -952,11 +1003,11 @@ describe('listAllItemsConnection — owner scoping', () => {
952
1003
 
953
1004
  // The read window exists because a FilterExpression cuts rows AFTER `Limit`.
954
1005
  // A key condition does not, so a scoped caller with no filter of their own
955
- // examines exactly the page they asked for — the arithmetic the window was
956
- // hiding, gone rather than papered over.
957
- test('a scoped caller with no filter examines exactly the page', () => {
1006
+ // examines the page they asked for and the one row that says whether another
1007
+ // follows — the arithmetic the window was hiding, gone rather than papered over.
1008
+ test('a scoped caller with no filter examines only the page, plus one', () => {
958
1009
  const { request } = indexed()
959
- expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(25)
1010
+ expect(request(makeCtx({ args: { first: 25 }, identity: asUser('cust-a') })).limit).toBe(26)
960
1011
  })
961
1012
 
962
1013
  test("a scoped caller's own filter still widens the window", () => {