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

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,13 @@
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.5 (2026-08-23)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **querydb:** serve the previous page instead of refusing it ([48341c9](https://github.com/ReventlessDev/reventless-core/commit/48341c9f61b5155b441deeb0edc9abf461948346))
11
+
12
+
6
13
  # 3.0.0-alpha.4 (2026-08-23)
7
14
 
8
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/rescript-pulumi-aws",
3
- "version": "3.0.0-alpha.4",
3
+ "version": "3.0.0-alpha.5",
4
4
  "description": "ReScript bindings for @pulumi/aws",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -563,6 +563,18 @@ saying whether a filter was pushed down.
563
563
  let pageWindowBudget = (~filtered: string) =>
564
564
  `(${filtered} ? (_first > 1000 ? _first : 1000) : _first + _from)`
565
565
 
566
+ /**
567
+ The same budget for the full-list door, which may also read backward.
568
+
569
+ A backward page is the slice `[_from, _upTo)` of the window, so the read has to
570
+ reach `_upTo` rows into it — `_first + _from` would stop short and hand back a
571
+ page with its tail missing.
572
+ */
573
+ let listPageWindowBudget = {
574
+ let need = "(_backward ? _upTo : _first + _from)"
575
+ `(parts.length > 0 ? (${need} > 1000 ? ${need} : 1000) : ${need})`
576
+ }
577
+
566
578
  /**
567
579
  Decodes `after` into the window it names (`t`, the token opening it) and the row's
568
580
  index among that window's matches (`n`). Pre-window cursors carried
@@ -597,21 +609,74 @@ let cursorPathGuard = `
597
609
  util.error('This cursor belongs to a different read of this list; restart from the first page.', 'CursorPathMismatch');
598
610
  }`
599
611
 
612
+ /**
613
+ The full-list door's cursor decode, which unlike `cursorDecode` may run backward.
614
+
615
+ A `{t, n}` cursor names a position INSIDE a read window, and a window is re-read
616
+ from its own token — which forward paging already relies on to resume mid-window.
617
+ Backward is the same move in the other direction: re-read the window `before`
618
+ names and cut the page that ends at `n`, `[n - first, n)`. No new cursor shape, no
619
+ index, and no assumption forward paging does not already make.
620
+
621
+ What it cannot do is cross into an EARLIER window: DynamoDB's continuation chain
622
+ walks one way, so a window cannot name the one before it. `_upTo <= 0` with a
623
+ non-null window is that case, and it is the only one still refused.
624
+
625
+ Declares `_first` (the response's slicing needs it before `connectionPageResponse`
626
+ would have declared it, so that one omits it here).
627
+ */
628
+ let listCursorPreamble = `
629
+ const _first = ctx.args.first ?? 50;
630
+ let _window = null;
631
+ let _from = 0;
632
+ let _cursorPath = null;
633
+ let _backward = false;
634
+ let _upTo = -1;
635
+ if (ctx.args.before != null && ctx.args.before !== '') {
636
+ const _c = JSON.parse(util.base64Decode(ctx.args.before));
637
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
638
+ _cursorPath = _c.p ?? 's';
639
+ _backward = true;
640
+ _upTo = _c.n !== undefined ? _c.n : 0;
641
+ _from = (_upTo - _first) > 0 ? (_upTo - _first) : 0;
642
+ } else if (ctx.args.after != null && ctx.args.after !== '') {
643
+ const _c = JSON.parse(util.base64Decode(ctx.args.after));
644
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
645
+ _from = _c.n !== undefined ? _c.n + 1 : 0;
646
+ _cursorPath = _c.p ?? 's';
647
+ }`
648
+
600
649
  /**
601
650
  Cuts the requested page out of the returned window. Expects `items` (sorted, if the
602
651
  door sorts) plus `_window` / `_from` from `cursorDecode`. `pathExpr` is the JS
603
652
  expression naming which read minted these cursors, for a door that has two.
653
+
654
+ `bidirectional` is the full-list door, whose page may have been cut backward: it
655
+ reads `_backward` / `_upTo` from `listCursorPreamble` and takes `_first` from
656
+ there rather than declaring its own.
604
657
  */
605
- let connectionPageResponse = (~pathExpr: option<string>=?) => {
658
+ let connectionPageResponse = (~pathExpr: option<string>=?, ~bidirectional: bool=false) => {
606
659
  let tag = switch pathExpr {
607
660
  | None => ""
608
661
  | Some(e) => `, p: ${e}`
609
662
  }
610
- `
611
- const _first = ctx.args.first ?? 50;
663
+ let firstDecl = bidirectional ? "" : "\n const _first = ctx.args.first ?? 50;"
664
+ let slice = bidirectional
665
+ ? `
666
+ const _rest = _backward ? items.slice(_from, _upTo) : items.slice(_from);
667
+ const _page = _backward ? _rest : _rest.slice(0, _first);
668
+ // A backward page was cut from ahead, so a next page provably exists — the one
669
+ // the caller came from — whatever this window's tail looks like.
670
+ const _more = _backward ? true : _rest.length > _first;`
671
+ : `
612
672
  const _rest = items.slice(_from);
613
673
  const _page = _rest.slice(0, _first);
614
- const _more = _rest.length > _first;
674
+ const _more = _rest.length > _first;`
675
+ // Only what the door can actually serve. `!!ctx.args.after` said "a page
676
+ // precedes this one", which is a different claim: at a window boundary one does
677
+ // and this door cannot reach it, so the client drew a Prev that always errored.
678
+ let hasPrevious = bidirectional ? "_from > 0" : "!!ctx.args.after"
679
+ `${firstDecl}${slice}
615
680
  const _next = ctx.result?.nextToken ?? null;
616
681
  const _lastIndex = _page.length - 1;
617
682
  // A row's cursor names its own position. The last row of a page that closes its
@@ -632,7 +697,7 @@ let connectionPageResponse = (~pathExpr: option<string>=?) => {
632
697
  edges,
633
698
  pageInfo: {
634
699
  hasNextPage: _more || !!_next,
635
- hasPreviousPage: !!ctx.args.after,
700
+ hasPreviousPage: ${hasPrevious},
636
701
  startCursor: edges.length > 0 ? edges[0].cursor : _boundary,
637
702
  endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : _boundary,
638
703
  },
@@ -1039,7 +1104,7 @@ ${switch retiredValues {
1039
1104
  const req = _exempt
1040
1105
  ? {
1041
1106
  operation: 'Scan',
1042
- limit: ${pageWindowBudget(~filtered="parts.length > 0")},
1107
+ limit: ${listPageWindowBudget},
1043
1108
  nextToken: _window,
1044
1109
  }
1045
1110
  : {
@@ -1050,14 +1115,14 @@ ${switch retiredValues {
1050
1115
  expressionNames: { '#owner': '${field}' },
1051
1116
  expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
1052
1117
  },
1053
- limit: ${pageWindowBudget(~filtered="parts.length > 0")},
1118
+ limit: ${listPageWindowBudget},
1054
1119
  nextToken: _window,
1055
1120
  scanIndexForward: !(_indexOrdered && ctx.args.orderBy.direction === 'DESC'),
1056
1121
  };`
1057
1122
  | _ => `
1058
1123
  const req = {
1059
1124
  operation: 'Scan',
1060
- limit: ${pageWindowBudget(~filtered="parts.length > 0")},
1125
+ limit: ${listPageWindowBudget},
1061
1126
  nextToken: _window,
1062
1127
  };`
1063
1128
  }
@@ -1070,16 +1135,18 @@ ${switch retiredValues {
1070
1135
  const _indexOrdered = ${indexOrderedExpr};`
1071
1136
  }
1072
1137
  let pageResponse = switch ownerIndex {
1073
- | None => connectionPageResponse()
1074
- | Some(_) => connectionPageResponse(~pathExpr="_path")
1138
+ | None => connectionPageResponse(~bidirectional=true)
1139
+ | Some(_) => connectionPageResponse(~pathExpr="_path", ~bidirectional=true)
1075
1140
  }
1076
1141
  `${importUtil}
1077
1142
  export function request(ctx) {
1078
- // Scan cannot page backward (ScanIndexForward is Query-only). Fail loud rather than
1079
- // silently returning the forward page. The ordered {single}Items connection
1080
- // (queryItemsWithSortConditions) supports last/before direct backward callers there.
1081
- if (ctx.args.before != null || ctx.args.last != null) {
1082
- util.error('Backward pagination (last/before) is not supported on full-list connections; use first/after.', 'UnsupportedPagination');
1143
+ // 'before' IS served — the page is cut backward out of the window the cursor
1144
+ // names. 'last' is not: "the last N rows of the list" needs the end of the
1145
+ // list, which a forward-only Scan cursor cannot reach. The ordered
1146
+ // {single}Items connection (queryItemsWithSortConditions) has a real keyset
1147
+ // cursor and honours both direct callers who need 'last' there.
1148
+ if (ctx.args.last != null) {
1149
+ util.error('last is not supported on full-list connections; page backward with first and before.', 'UnsupportedPagination');
1083
1150
  }
1084
1151
  const filter = ctx.args.filter ?? {};
1085
1152
  const names = {};
@@ -1104,8 +1171,14 @@ export function request(ctx) {
1104
1171
  });
1105
1172
  parts.push('#id IN (' + placeholders.join(', ') + ')');
1106
1173
  }${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}${retiredClause}
1107
- ${cursorDecode(~args="ctx.args")}${requestPathGuard}
1108
- const _first = ctx.args.first ?? 50;${requestOperation}
1174
+ ${listCursorPreamble}${requestPathGuard}
1175
+ // The one page a cursor cannot reach: it begins in an earlier window, and a
1176
+ // continuation token cannot name the one before it. Refused by itself rather
1177
+ // than folded into the 'last' guard, because the two are different limits and a
1178
+ // caller can act on this one (page forward from the start).
1179
+ if (_backward && _upTo <= 0 && _window !== null) {
1180
+ util.error('The previous page begins in an earlier read window, which this cursor cannot name; page forward from the start.', 'UnsupportedPagination');
1181
+ }${requestOperation}
1109
1182
  if (parts.length > 0) {
1110
1183
  req.filter = {
1111
1184
  expression: parts.join(' AND '),
@@ -1117,9 +1190,7 @@ ${cursorDecode(~args="ctx.args")}${requestPathGuard}
1117
1190
  }
1118
1191
  export function response(ctx) {
1119
1192
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
1120
- let items = ctx.result?.items ?? [];${responsePathPreamble}${sortBlock}${cursorDecode(
1121
- ~args="ctx.args",
1122
- )}${pageResponse}
1193
+ let items = ctx.result?.items ?? [];${responsePathPreamble}${sortBlock}${listCursorPreamble}${pageResponse}
1123
1194
  }
1124
1195
  `->Pulumi.Input.make
1125
1196
  }
@@ -392,6 +392,10 @@ function pageWindowBudget(filtered) {
392
392
  return `(` + filtered + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`;
393
393
  }
394
394
 
395
+ let need = "(_backward ? _upTo : _first + _from)";
396
+
397
+ let listPageWindowBudget = `(parts.length > 0 ? (` + need + ` > 1000 ? ` + need + ` : 1000) : ` + need + `)`;
398
+
395
399
  function cursorDecode(args) {
396
400
  return `
397
401
  let _window = null;
@@ -410,13 +414,42 @@ let cursorPathGuard = `
410
414
  util.error('This cursor belongs to a different read of this list; restart from the first page.', 'CursorPathMismatch');
411
415
  }`;
412
416
 
413
- function connectionPageResponse(pathExpr) {
414
- let tag = pathExpr !== undefined ? `, p: ` + pathExpr : "";
415
- return `
417
+ let listCursorPreamble = `
416
418
  const _first = ctx.args.first ?? 50;
419
+ let _window = null;
420
+ let _from = 0;
421
+ let _cursorPath = null;
422
+ let _backward = false;
423
+ let _upTo = -1;
424
+ if (ctx.args.before != null && ctx.args.before !== '') {
425
+ const _c = JSON.parse(util.base64Decode(ctx.args.before));
426
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
427
+ _cursorPath = _c.p ?? 's';
428
+ _backward = true;
429
+ _upTo = _c.n !== undefined ? _c.n : 0;
430
+ _from = (_upTo - _first) > 0 ? (_upTo - _first) : 0;
431
+ } else if (ctx.args.after != null && ctx.args.after !== '') {
432
+ const _c = JSON.parse(util.base64Decode(ctx.args.after));
433
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
434
+ _from = _c.n !== undefined ? _c.n + 1 : 0;
435
+ _cursorPath = _c.p ?? 's';
436
+ }`;
437
+
438
+ function connectionPageResponse(pathExpr, bidirectionalOpt) {
439
+ let bidirectional = bidirectionalOpt !== undefined ? bidirectionalOpt : false;
440
+ let tag = pathExpr !== undefined ? `, p: ` + pathExpr : "";
441
+ let firstDecl = bidirectional ? "" : "\n const _first = ctx.args.first ?? 50;";
442
+ let slice = bidirectional ? `
443
+ const _rest = _backward ? items.slice(_from, _upTo) : items.slice(_from);
444
+ const _page = _backward ? _rest : _rest.slice(0, _first);
445
+ // A backward page was cut from ahead, so a next page provably exists — the one
446
+ // the caller came from — whatever this window's tail looks like.
447
+ const _more = _backward ? true : _rest.length > _first;` : `
417
448
  const _rest = items.slice(_from);
418
449
  const _page = _rest.slice(0, _first);
419
- const _more = _rest.length > _first;
450
+ const _more = _rest.length > _first;`;
451
+ let hasPrevious = bidirectional ? "_from > 0" : "!!ctx.args.after";
452
+ return firstDecl + slice + `
420
453
  const _next = ctx.result?.nextToken ?? null;
421
454
  const _lastIndex = _page.length - 1;
422
455
  // A row's cursor names its own position. The last row of a page that closes its
@@ -437,7 +470,7 @@ function connectionPageResponse(pathExpr) {
437
470
  edges,
438
471
  pageInfo: {
439
472
  hasNextPage: _more || !!_next,
440
- hasPreviousPage: !!ctx.args.after,
473
+ hasPreviousPage: ` + hasPrevious + `,
441
474
  startCursor: edges.length > 0 ? edges[0].cursor : _boundary,
442
475
  endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : _boundary,
443
476
  },
@@ -447,7 +480,7 @@ function connectionPageResponse(pathExpr) {
447
480
  let indexConnectionResponseCode = `
448
481
  export function response(ctx) {
449
482
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
450
- const items = ctx.result?.items ?? [];` + cursorDecode("ctx.args") + connectionPageResponse(undefined) + `
483
+ const items = ctx.result?.items ?? [];` + cursorDecode("ctx.args") + connectionPageResponse(undefined, undefined) + `
451
484
  }`;
452
485
 
453
486
  let indexBackwardPagingGuard = `
@@ -686,7 +719,7 @@ function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sor
686
719
  const req = _exempt
687
720
  ? {
688
721
  operation: 'Scan',
689
- limit: ` + (`(` + "parts.length > 0" + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`) + `,
722
+ limit: ` + listPageWindowBudget + `,
690
723
  nextToken: _window,
691
724
  }
692
725
  : {
@@ -697,33 +730,35 @@ function listAllItemsConnection(labelField, filterFieldsOpt, rangeFieldsOpt, sor
697
730
  expressionNames: { '#owner': '` + ownerField + `' },
698
731
  expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
699
732
  },
700
- limit: ` + (`(` + "parts.length > 0" + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`) + `,
733
+ limit: ` + listPageWindowBudget + `,
701
734
  nextToken: _window,
702
735
  scanIndexForward: !(_indexOrdered && ctx.args.orderBy.direction === 'DESC'),
703
736
  };` : `
704
737
  const req = {
705
738
  operation: 'Scan',
706
- limit: ` + (`(` + "parts.length > 0" + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`) + `,
739
+ limit: ` + listPageWindowBudget + `,
707
740
  nextToken: _window,
708
741
  };`
709
742
  ) : `
710
743
  const req = {
711
744
  operation: 'Scan',
712
- limit: ` + (`(` + "parts.length > 0" + ` ? (_first > 1000 ? _first : 1000) : _first + _from)`) + `,
745
+ limit: ` + listPageWindowBudget + `,
713
746
  nextToken: _window,
714
747
  };`;
715
748
  let requestPathGuard = Stdlib_Option.isSome(ownerIndex$1) ? cursorPathGuard : "";
716
749
  let responsePathPreamble = ownerIndex$1 !== undefined ? ownerIdentityPreamble + `
717
750
  const _path = _exempt ? 's' : 'q';
718
751
  const _indexOrdered = ` + indexOrderedExpr + `;` : "";
719
- let pageResponse = ownerIndex$1 !== undefined ? connectionPageResponse("_path") : connectionPageResponse(undefined);
752
+ let pageResponse = ownerIndex$1 !== undefined ? connectionPageResponse("_path", true) : connectionPageResponse(undefined, true);
720
753
  return importUtil + `
721
754
  export function request(ctx) {
722
- // Scan cannot page backward (ScanIndexForward is Query-only). Fail loud rather than
723
- // silently returning the forward page. The ordered {single}Items connection
724
- // (queryItemsWithSortConditions) supports last/before direct backward callers there.
725
- if (ctx.args.before != null || ctx.args.last != null) {
726
- util.error('Backward pagination (last/before) is not supported on full-list connections; use first/after.', 'UnsupportedPagination');
755
+ // 'before' IS served — the page is cut backward out of the window the cursor
756
+ // names. 'last' is not: "the last N rows of the list" needs the end of the
757
+ // list, which a forward-only Scan cursor cannot reach. The ordered
758
+ // {single}Items connection (queryItemsWithSortConditions) has a real keyset
759
+ // cursor and honours both direct callers who need 'last' there.
760
+ if (ctx.args.last != null) {
761
+ util.error('last is not supported on full-list connections; page backward with first and before.', 'UnsupportedPagination');
727
762
  }
728
763
  const filter = ctx.args.filter ?? {};
729
764
  const names = {};
@@ -748,8 +783,14 @@ export function request(ctx) {
748
783
  });
749
784
  parts.push('#id IN (' + placeholders.join(', ') + ')');
750
785
  }` + filterClauses + rangeClauses + requireAttributeClause + ownerClause + retiredClause + `
751
- ` + cursorDecode("ctx.args") + requestPathGuard + `
752
- const _first = ctx.args.first ?? 50;` + requestOperation + `
786
+ ` + listCursorPreamble + requestPathGuard + `
787
+ // The one page a cursor cannot reach: it begins in an earlier window, and a
788
+ // continuation token cannot name the one before it. Refused by itself rather
789
+ // than folded into the 'last' guard, because the two are different limits and a
790
+ // caller can act on this one (page forward from the start).
791
+ if (_backward && _upTo <= 0 && _window !== null) {
792
+ util.error('The previous page begins in an earlier read window, which this cursor cannot name; page forward from the start.', 'UnsupportedPagination');
793
+ }` + requestOperation + `
753
794
  if (parts.length > 0) {
754
795
  req.filter = {
755
796
  expression: parts.join(' AND '),
@@ -761,7 +802,7 @@ export function request(ctx) {
761
802
  }
762
803
  export function response(ctx) {
763
804
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
764
- let items = ctx.result?.items ?? [];` + responsePathPreamble + sortBlock + cursorDecode("ctx.args") + pageResponse + `
805
+ let items = ctx.result?.items ?? [];` + responsePathPreamble + sortBlock + listCursorPreamble + pageResponse + `
765
806
  }
766
807
  `;
767
808
  }
@@ -1289,8 +1330,10 @@ export {
1289
1330
  queryByIndexDeletable,
1290
1331
  queryByIndexSort,
1291
1332
  pageWindowBudget,
1333
+ listPageWindowBudget,
1292
1334
  cursorDecode,
1293
1335
  cursorPathGuard,
1336
+ listCursorPreamble,
1294
1337
  connectionPageResponse,
1295
1338
  indexConnectionResponseCode,
1296
1339
  indexBackwardPagingGuard,
@@ -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.
@@ -1025,16 +1070,37 @@ describe('listAllItemsConnection — owner scoping', () => {
1025
1070
  })
1026
1071
  })
1027
1072
 
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.
1073
+ // Backward paging works the same way on both branches, and for the same reason:
1074
+ // it re-reads the window the cursor names and cuts the page ending at it, which
1075
+ // is operation-agnostic. `last` stays refused on both it needs the end of the
1076
+ // list, which neither a Scan nor this Query's cursor can reach.
1031
1077
  test.each([['scoped', asUser('cust-a')], ['exempt', asUser('ops-1', ['Admin'])]])(
1032
- 'a %s caller is still refused backward paging',
1078
+ 'a %s caller is still refused `last`',
1033
1079
  (_, identity) => {
1034
1080
  const { request } = indexed()
1035
- expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow('Backward pagination')
1081
+ expect(() => request(makeCtx({ args: { last: 5 }, identity }))).toThrow(
1082
+ 'last is not supported',
1083
+ )
1036
1084
  },
1037
1085
  )
1086
+
1087
+ // The path tag has to survive the direction change: a backward cursor still
1088
+ // names the read that minted it, and replaying it on the other branch is the
1089
+ // same mistake as replaying a forward one.
1090
+ test('a backward cursor is path-checked like a forward one', () => {
1091
+ const { request, response } = indexed()
1092
+ const scopedPage = response(makeCtx({
1093
+ args: { first: 10 },
1094
+ identity: asUser('cust-a'),
1095
+ result: { items: Array.from({ length: 10 }, (_, i) => ({ id: 'r' + i })), nextToken: null },
1096
+ }))
1097
+ expect(() =>
1098
+ request(makeCtx({
1099
+ args: { first: 10, before: scopedPage.pageInfo.endCursor },
1100
+ identity: asUser('ops-1', ['Admin']),
1101
+ })),
1102
+ ).toThrow('different read of this list')
1103
+ })
1038
1104
  })
1039
1105
 
1040
1106
  // ---------------------------------------------------------------------------