@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
@@ -4,12 +4,11 @@
4
4
  exported `request` and `response` functions, ready to be passed as the `code`
5
5
  field of AppSync_Resolver.makeUnitJsResolver / makePipelineJsResolver.
6
6
  */
7
-
8
- // ---------------------------------------------------------------------------
7
+ let // ---------------------------------------------------------------------------
9
8
  // Shared response snippets (inlined into each resolver's template literal)
10
9
  // ---------------------------------------------------------------------------
11
10
 
12
- let resultResponseCode = `
11
+ resultResponseCode = `
13
12
  export function response(ctx) {
14
13
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
15
14
  return ctx.result;
@@ -62,6 +61,21 @@ let exemptPreamble = (~elevatedGroups: array<string>) => {
62
61
  const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`
63
62
  }
64
63
 
64
+ /**
65
+ A view row's `_change` field: the stored value for an exempt caller, null for anyone else.
66
+ Answered from the request step, so the view's data source is never called.
67
+ */
68
+ let rowChangeField = (~field: string, ~elevatedGroups: array<string>): Pulumi.Input.t<string> =>
69
+ `import { util, runtime } from '@aws-appsync/utils';
70
+ export function request(ctx) {${exemptPreamble(~elevatedGroups)}
71
+ const _row = ctx.source;
72
+ return runtime.earlyReturn(_exempt && _row != null && _row['${field}'] != null ? _row['${field}'] : null);
73
+ }
74
+ export function response(ctx) {
75
+ return ctx.result;
76
+ }
77
+ `->Pulumi.Input.make
78
+
65
79
  /**
66
80
  A by-key read's retirement guard: `_live(row)`, true when the caller may see it.
67
81
  The post-read half of what `listAllItemsConnection` pushes into a
@@ -191,15 +205,18 @@ let ownerScopedResultResponse = (
191
205
  | _ =>
192
206
  let ownerPart = switch ownerField {
193
207
  // Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
194
- // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
195
- // arguments, but got 1") and AppSync rejects the whole resolver at create
196
- // time with "The code contains one or more errors". Only a door that emits
197
- // the call conditionally is safe with a bare `() => true`, and that is not a
198
- // property worth relying on across three templates.
199
- | None => "\n const _owns = (row) => true;"
208
+ // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
209
+ // arguments, but got 1") and AppSync rejects the whole resolver at create
210
+ // time with "The code contains one or more errors". Only a door that emits
211
+ // the call conditionally is safe with a bare `() => true`, and that is not a
212
+ // property worth relying on across three templates.
213
+ | None => "\n const _owns = (row) => true;"
200
214
  | Some(field) =>
201
215
  `
202
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
216
+ // ── owner scoping (generated) ──${ownerGuardPreamble(
217
+ ~ownerField=field,
218
+ ~elevatedGroups,
219
+ )}`
203
220
  }
204
221
  let retiredPart = retiredGuardPreamble(
205
222
  ~retiredField,
@@ -210,7 +227,9 @@ let ownerScopedResultResponse = (
210
227
  `
211
228
  export function response(ctx) {
212
229
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
213
- return _owns(ctx.result)${retiredField->Option.isSome ? " && _live(ctx.result)" : ""} ? ctx.result : null;
230
+ return _owns(ctx.result)${retiredField->Option.isSome
231
+ ? " && _live(ctx.result)"
232
+ : ""} ? ctx.result : null;
214
233
  }`
215
234
  }
216
235
 
@@ -226,15 +245,18 @@ let ownerScopedFirstResultResponse = (
226
245
  | _ =>
227
246
  let ownerPart = switch ownerField {
228
247
  // 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;"
248
+ // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
249
+ // arguments, but got 1") and AppSync rejects the whole resolver at create
250
+ // time with "The code contains one or more errors". Only a door that emits
251
+ // the call conditionally is safe with a bare `() => true`, and that is not a
252
+ // property worth relying on across three templates.
253
+ | None => "\n const _owns = (row) => true;"
235
254
  | Some(field) =>
236
255
  `
237
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
256
+ // ── owner scoping (generated) ──${ownerGuardPreamble(
257
+ ~ownerField=field,
258
+ ~elevatedGroups,
259
+ )}`
238
260
  }
239
261
  let retiredPart = retiredGuardPreamble(
240
262
  ~retiredField,
@@ -255,8 +277,7 @@ export function response(ctx) {
255
277
  // ---------------------------------------------------------------------------
256
278
 
257
279
  /** Pipeline resolver code with no pre-processing and a standard error-check response. */
258
- let pipelinePassThrough =
259
- `${importUtil}
280
+ let pipelinePassThrough = `${importUtil}
260
281
  export function request(ctx) { return {}; }
261
282
  ${resultResponseCode}
262
283
  `->Pulumi.Input.make
@@ -329,8 +350,7 @@ export function request(ctx) {
329
350
  ${ownerScopedResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
330
351
  `->Pulumi.Input.make
331
352
 
332
- let queryById =
333
- `${importUtil}
353
+ let queryById = `${importUtil}
334
354
  export function request(ctx) {
335
355
  return {
336
356
  operation: 'Query',
@@ -348,11 +368,11 @@ ${resultResponseCode}
348
368
  Relay pagination: `first`/`after` (forward) or `last`/`before` (backward).
349
369
  Cursor is base64 of the sort key value.
350
370
  Returns a Relay `{ edges, pageInfo }` shape reusing the entity's `Connection` type. */
351
- // A list in everything but its name, so it scopes the way `listAllItemsConnection`
371
+ let // A list in everything but its name, so it scopes the way `listAllItemsConnection`
352
372
  // does — a FilterExpression on the request, not a guard on the response. The
353
373
  // response is where the page is cut, and narrowing after that cut would report
354
374
  // `hasNextPage` from a count the caller was never allowed to see.
355
- let queryItemsWithSortConditions = (
375
+ queryItemsWithSortConditions = (
356
376
  sortField: string,
357
377
  ~ownerField: option<string>=?,
358
378
  ~elevatedGroups: array<string>=[],
@@ -559,9 +579,26 @@ the survivors serves short — usually empty — pages under a selective filter.
559
579
  no loops in APPSYNC_JS the door reads wider instead and addresses the surplus by
560
580
  position. 1 MB caps a page anyway, hence 1000; `filtered` is the JS expression
561
581
  saying whether a filter was pushed down.
582
+
583
+ An unfiltered read takes one row past the page. DynamoDB hands back a token
584
+ whenever it stops AT the limit, end of data or not, so a read of exactly the page
585
+ reports a next page that comes back blank; the extra row is what says one exists.
562
586
  */
563
587
  let pageWindowBudget = (~filtered: string) =>
564
- `(${filtered} ? (_first > 1000 ? _first : 1000) : _first + _from)`
588
+ `(${filtered} ? (_first > 1000 ? _first : 1000) : _first + _from + 1)`
589
+
590
+ /**
591
+ The same budget for the full-list door, which may also read backward.
592
+
593
+ A backward page is the slice `[_from, _upTo)` of the window, so the read has to
594
+ reach `_upTo` rows into it — `_first + _from` would stop short and hand back a
595
+ page with its tail missing. Forward reads one row past the page, for the reason
596
+ `pageWindowBudget` gives; a backward page needs none, as one provably follows it.
597
+ */
598
+ let listPageWindowBudget = {
599
+ let need = "(_backward ? _upTo : _first + _from + 1)"
600
+ `(parts.length > 0 ? (${need} > 1000 ? ${need} : 1000) : ${need})`
601
+ }
565
602
 
566
603
  /**
567
604
  Decodes `after` into the window it names (`t`, the token opening it) and the row's
@@ -573,7 +610,8 @@ index among that window's matches (`n`). Pre-window cursors carried
573
610
  tag existed came off a Scan. Only a door with more than one read tests it — see
574
611
  `cursorPathGuard`.
575
612
  */
576
- let cursorDecode = (~args: string) => `
613
+ let cursorDecode = (~args: string) =>
614
+ `
577
615
  let _window = null;
578
616
  let _from = 0;
579
617
  let _cursorPath = null;
@@ -597,21 +635,74 @@ let cursorPathGuard = `
597
635
  util.error('This cursor belongs to a different read of this list; restart from the first page.', 'CursorPathMismatch');
598
636
  }`
599
637
 
638
+ /**
639
+ The full-list door's cursor decode, which unlike `cursorDecode` may run backward.
640
+
641
+ A `{t, n}` cursor names a position INSIDE a read window, and a window is re-read
642
+ from its own token — which forward paging already relies on to resume mid-window.
643
+ Backward is the same move in the other direction: re-read the window `before`
644
+ names and cut the page that ends at `n`, `[n - first, n)`. No new cursor shape, no
645
+ index, and no assumption forward paging does not already make.
646
+
647
+ What it cannot do is cross into an EARLIER window: DynamoDB's continuation chain
648
+ walks one way, so a window cannot name the one before it. `_upTo <= 0` with a
649
+ non-null window is that case, and it is the only one still refused.
650
+
651
+ Declares `_first` (the response's slicing needs it before `connectionPageResponse`
652
+ would have declared it, so that one omits it here).
653
+ */
654
+ let listCursorPreamble = `
655
+ const _first = ctx.args.first ?? 50;
656
+ let _window = null;
657
+ let _from = 0;
658
+ let _cursorPath = null;
659
+ let _backward = false;
660
+ let _upTo = -1;
661
+ if (ctx.args.before != null && ctx.args.before !== '') {
662
+ const _c = JSON.parse(util.base64Decode(ctx.args.before));
663
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
664
+ _cursorPath = _c.p ?? 's';
665
+ _backward = true;
666
+ _upTo = _c.n !== undefined ? _c.n : 0;
667
+ _from = (_upTo - _first) > 0 ? (_upTo - _first) : 0;
668
+ } else if (ctx.args.after != null && ctx.args.after !== '') {
669
+ const _c = JSON.parse(util.base64Decode(ctx.args.after));
670
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
671
+ _from = _c.n !== undefined ? _c.n + 1 : 0;
672
+ _cursorPath = _c.p ?? 's';
673
+ }`
674
+
600
675
  /**
601
676
  Cuts the requested page out of the returned window. Expects `items` (sorted, if the
602
677
  door sorts) plus `_window` / `_from` from `cursorDecode`. `pathExpr` is the JS
603
678
  expression naming which read minted these cursors, for a door that has two.
679
+
680
+ `bidirectional` is the full-list door, whose page may have been cut backward: it
681
+ reads `_backward` / `_upTo` from `listCursorPreamble` and takes `_first` from
682
+ there rather than declaring its own.
604
683
  */
605
- let connectionPageResponse = (~pathExpr: option<string>=?) => {
684
+ let connectionPageResponse = (~pathExpr: option<string>=?, ~bidirectional: bool=false) => {
606
685
  let tag = switch pathExpr {
607
686
  | None => ""
608
687
  | Some(e) => `, p: ${e}`
609
688
  }
610
- `
611
- const _first = ctx.args.first ?? 50;
689
+ let firstDecl = bidirectional ? "" : "\n const _first = ctx.args.first ?? 50;"
690
+ let slice = bidirectional
691
+ ? `
692
+ const _rest = _backward ? items.slice(_from, _upTo) : items.slice(_from);
693
+ const _page = _backward ? _rest : _rest.slice(0, _first);
694
+ // A backward page was cut from ahead, so a next page provably exists — the one
695
+ // the caller came from — whatever this window's tail looks like.
696
+ const _more = _backward ? true : _rest.length > _first;`
697
+ : `
612
698
  const _rest = items.slice(_from);
613
699
  const _page = _rest.slice(0, _first);
614
- const _more = _rest.length > _first;
700
+ const _more = _rest.length > _first;`
701
+ // Only what the door can actually serve. `!!ctx.args.after` said "a page
702
+ // precedes this one", which is a different claim: at a window boundary one does
703
+ // and this door cannot reach it, so the client drew a Prev that always errored.
704
+ let hasPrevious = bidirectional ? "_from > 0" : "!!ctx.args.after"
705
+ `${firstDecl}${slice}
615
706
  const _next = ctx.result?.nextToken ?? null;
616
707
  const _lastIndex = _page.length - 1;
617
708
  // A row's cursor names its own position. The last row of a page that closes its
@@ -632,7 +723,7 @@ let connectionPageResponse = (~pathExpr: option<string>=?) => {
632
723
  edges,
633
724
  pageInfo: {
634
725
  hasNextPage: _more || !!_next,
635
- hasPreviousPage: !!ctx.args.after,
726
+ hasPreviousPage: ${hasPrevious},
636
727
  startCursor: edges.length > 0 ? edges[0].cursor : _boundary,
637
728
  endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : _boundary,
638
729
  },
@@ -652,7 +743,7 @@ export function response(ctx) {
652
743
 
653
744
  /** Refuses backward paging, for the reason `listAllItemsConnection` gives: the
654
745
  cursor is DynamoDB's own continuation token, which only walks forward, so
655
- `last`/`before` cannot be honoured, and handing back the forward page would answer
746
+ `last`/`before` cannot be honored, and handing back the forward page would answer
656
747
  a different question without saying so. The arguments stay declared — one that
657
748
  came and went with the index's shape would make every client feature-detect — and
658
749
  the local backend refuses them with the same message. */
@@ -714,7 +805,11 @@ export function request(ctx) {
714
805
  values[':' + key] = '' + value;
715
806
  }
716
807
  });
717
- ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
808
+ ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(
809
+ ~retiredField,
810
+ ~retiredValues,
811
+ ~elevatedGroups,
812
+ )}${indexCursorPreamble}
718
813
  const result = {
719
814
  operation: 'Query',
720
815
  query,
@@ -777,7 +872,11 @@ export function request(ctx) {
777
872
  values[':' + key] = '' + value;
778
873
  }
779
874
  });
780
- ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
875
+ ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(
876
+ ~retiredField,
877
+ ~retiredValues,
878
+ ~elevatedGroups,
879
+ )}${indexCursorPreamble}
781
880
  const result = {
782
881
  operation: 'Query',
783
882
  query,
@@ -798,8 +897,7 @@ ${indexConnectionResponseCode}
798
897
  // DynamoDB read — list all
799
898
  // ---------------------------------------------------------------------------
800
899
 
801
- let listAllItems =
802
- `${importUtil}
900
+ let listAllItems = `${importUtil}
803
901
  export function request(ctx) {
804
902
  return {
805
903
  operation: 'Scan',
@@ -863,7 +961,8 @@ let listAllItemsConnection = (
863
961
  // have nothing to name in the key condition.
864
962
  let ownerIndex = ownerField->Option.isSome ? ownerIndex : None
865
963
  let requireAttributeClause = switch requireAttribute {
866
- | Some(attr) => `
964
+ | Some(attr) =>
965
+ `
867
966
  names['#${attr}'] = '${attr}';
868
967
  parts.push('attribute_exists(#${attr})');`
869
968
  | None => ""
@@ -909,7 +1008,7 @@ let listAllItemsConnection = (
909
1008
  // Reuses `_exempt` when the owner clause computed it; either clause may be the
910
1009
  // only one present. `includeRetired` IS read from ctx.args, unlike the owner
911
1010
  // predicate — it asks to lift a restriction rather than naming rows, and is
912
- // honoured only inside `_exempt`.
1011
+ // honored only inside `_exempt`.
913
1012
  //
914
1013
  // `attribute_not_exists OR = false` rather than `<> true`, because `<>` does not
915
1014
  // match a missing attribute and the view would empty out the day the annotation
@@ -921,7 +1020,8 @@ let listAllItemsConnection = (
921
1020
  let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
922
1021
  let exemptPrelude = switch ownerField {
923
1022
  | Some(_) => ""
924
- | None => `
1023
+ | None =>
1024
+ `
925
1025
  const _id = ctx.identity;
926
1026
  const _sub = _id == null ? null : _id.sub;
927
1027
  const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
@@ -942,10 +1042,13 @@ ${switch retiredValues {
942
1042
  // once for the whole clause — a row that states no lifecycle is not
943
1043
  // retired, the same reading every other adapter takes.
944
1044
  | Some(states) =>
945
- let placeholders = states->Array.mapWithIndex((state, i) => (`:retiredValue${Int.toString(i)}`, state))
1045
+ let placeholders =
1046
+ states->Array.mapWithIndex((state, i) => (`:retiredValue${Int.toString(i)}`, state))
946
1047
  let assignments =
947
1048
  placeholders
948
- ->Array.map(((ph, state)) => ` values['${ph}'] = util.dynamodb.toDynamoDB('${state}');`)
1049
+ ->Array.map(((ph, state)) =>
1050
+ ` values['${ph}'] = util.dynamodb.toDynamoDB('${state}');`
1051
+ )
949
1052
  ->Array.join("\n")
950
1053
  let comparisons =
951
1054
  placeholders->Array.map(((ph, _)) => `#retired <> ${ph}`)->Array.join(" AND ")
@@ -961,16 +1064,19 @@ ${switch retiredValues {
961
1064
  }
962
1065
  let filterClauses =
963
1066
  filterFields
964
- ->Array.map(f => `
1067
+ ->Array.map(f =>
1068
+ `
965
1069
  if (filter.${f}Eq !== undefined && filter.${f}Eq !== null && filter.${f}Eq !== '') {
966
1070
  names['#${f}'] = '${f}';
967
1071
  values[':${f}Eq'] = util.dynamodb.toDynamoDB(filter.${f}Eq);
968
1072
  parts.push('#${f} = :${f}Eq');
969
- }`)
1073
+ }`
1074
+ )
970
1075
  ->Array.join("")
971
1076
  let rangeClauses =
972
1077
  rangeFields
973
- ->Array.map(f => `
1078
+ ->Array.map(f =>
1079
+ `
974
1080
  if (filter.${f}From !== undefined && filter.${f}From !== null && filter.${f}From !== '') {
975
1081
  names['#${f}'] = '${f}';
976
1082
  values[':${f}From'] = util.dynamodb.toDynamoDB(filter.${f}From);
@@ -980,10 +1086,10 @@ ${switch retiredValues {
980
1086
  names['#${f}'] = '${f}';
981
1087
  values[':${f}To'] = util.dynamodb.toDynamoDB(filter.${f}To);
982
1088
  parts.push('#${f} <= :${f}To');
983
- }`)
1089
+ }`
1090
+ )
984
1091
  ->Array.join("")
985
- let sortFieldsLiteral =
986
- sortFields->Array.map(f => `'${f}'`)->Array.join(", ")
1092
+ let sortFieldsLiteral = sortFields->Array.map(f => `'${f}'`)->Array.join(", ")
987
1093
  // When the Query branch already ordered on the index's own sort key, the page
988
1094
  // arrives globally ordered and re-sorting it here is the one way to break that
989
1095
  // order — a sort over a page is not a sort over the caller's rows.
@@ -1025,8 +1131,7 @@ ${switch retiredValues {
1025
1131
  // halves, because both need it: the request to set `scanIndexForward`, the
1026
1132
  // response to leave an already-ordered page alone.
1027
1133
  let indexOrderedExpr = switch (ownerIndex, ownerIndexSortField) {
1028
- | (Some(_), Some(sf)) =>
1029
- `!_exempt && !!(ctx.args.orderBy && ctx.args.orderBy.field === '${sf}')`
1134
+ | (Some(_), Some(sf)) => `!_exempt && !!(ctx.args.orderBy && ctx.args.orderBy.field === '${sf}')`
1030
1135
  | _ => "false"
1031
1136
  }
1032
1137
  // The two reads, chosen by the same test that used to choose a predicate.
@@ -1034,12 +1139,13 @@ ${switch retiredValues {
1034
1139
  // branch inside one resolver — no second field, no second data source, no
1035
1140
  // client change.
1036
1141
  let requestOperation = switch (ownerField, ownerIndex) {
1037
- | (Some(field), Some(index)) => `
1142
+ | (Some(field), Some(index)) =>
1143
+ `
1038
1144
  const _indexOrdered = ${indexOrderedExpr};
1039
1145
  const req = _exempt
1040
1146
  ? {
1041
1147
  operation: 'Scan',
1042
- limit: ${pageWindowBudget(~filtered="parts.length > 0")},
1148
+ limit: ${listPageWindowBudget},
1043
1149
  nextToken: _window,
1044
1150
  }
1045
1151
  : {
@@ -1050,14 +1156,15 @@ ${switch retiredValues {
1050
1156
  expressionNames: { '#owner': '${field}' },
1051
1157
  expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
1052
1158
  },
1053
- limit: ${pageWindowBudget(~filtered="parts.length > 0")},
1159
+ limit: ${listPageWindowBudget},
1054
1160
  nextToken: _window,
1055
1161
  scanIndexForward: !(_indexOrdered && ctx.args.orderBy.direction === 'DESC'),
1056
1162
  };`
1057
- | _ => `
1163
+ | _ =>
1164
+ `
1058
1165
  const req = {
1059
1166
  operation: 'Scan',
1060
- limit: ${pageWindowBudget(~filtered="parts.length > 0")},
1167
+ limit: ${listPageWindowBudget},
1061
1168
  nextToken: _window,
1062
1169
  };`
1063
1170
  }
@@ -1065,21 +1172,24 @@ ${switch retiredValues {
1065
1172
  let requestPathGuard = ownerIndex->Option.isSome ? cursorPathGuard : ""
1066
1173
  let responsePathPreamble = switch ownerIndex {
1067
1174
  | None => ""
1068
- | Some(_) => `${ownerIdentityPreamble}
1175
+ | Some(_) =>
1176
+ `${ownerIdentityPreamble}
1069
1177
  const _path = _exempt ? 's' : 'q';
1070
1178
  const _indexOrdered = ${indexOrderedExpr};`
1071
1179
  }
1072
1180
  let pageResponse = switch ownerIndex {
1073
- | None => connectionPageResponse()
1074
- | Some(_) => connectionPageResponse(~pathExpr="_path")
1181
+ | None => connectionPageResponse(~bidirectional=true)
1182
+ | Some(_) => connectionPageResponse(~pathExpr="_path", ~bidirectional=true)
1075
1183
  }
1076
1184
  `${importUtil}
1077
1185
  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');
1186
+ // 'before' IS served — the page is cut backward out of the window the cursor
1187
+ // names. 'last' is not: "the last N rows of the list" needs the end of the
1188
+ // list, which a forward-only Scan cursor cannot reach. The ordered
1189
+ // {single}Items connection (queryItemsWithSortConditions) has a real keyset
1190
+ // cursor and honours both — direct callers who need 'last' there.
1191
+ if (ctx.args.last != null) {
1192
+ util.error('last is not supported on full-list connections; page backward with first and before.', 'UnsupportedPagination');
1083
1193
  }
1084
1194
  const filter = ctx.args.filter ?? {};
1085
1195
  const names = {};
@@ -1104,8 +1214,14 @@ export function request(ctx) {
1104
1214
  });
1105
1215
  parts.push('#id IN (' + placeholders.join(', ') + ')');
1106
1216
  }${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}${retiredClause}
1107
- ${cursorDecode(~args="ctx.args")}${requestPathGuard}
1108
- const _first = ctx.args.first ?? 50;${requestOperation}
1217
+ ${listCursorPreamble}${requestPathGuard}
1218
+ // The one page a cursor cannot reach: it begins in an earlier window, and a
1219
+ // continuation token cannot name the one before it. Refused by itself rather
1220
+ // than folded into the 'last' guard, because the two are different limits and a
1221
+ // caller can act on this one (page forward from the start).
1222
+ if (_backward && _upTo <= 0 && _window !== null) {
1223
+ util.error('The previous page begins in an earlier read window, which this cursor cannot name; page forward from the start.', 'UnsupportedPagination');
1224
+ }${requestOperation}
1109
1225
  if (parts.length > 0) {
1110
1226
  req.filter = {
1111
1227
  expression: parts.join(' AND '),
@@ -1117,9 +1233,7 @@ ${cursorDecode(~args="ctx.args")}${requestPathGuard}
1117
1233
  }
1118
1234
  export function response(ctx) {
1119
1235
  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}
1236
+ let items = ctx.result?.items ?? [];${responsePathPreamble}${sortBlock}${listCursorPreamble}${pageResponse}
1123
1237
  }
1124
1238
  `->Pulumi.Input.make
1125
1239
  }
@@ -1153,7 +1267,10 @@ let resolvedFieldResponse = (
1153
1267
  | None => "\n const _owns = (row) => true;"
1154
1268
  | Some(field) =>
1155
1269
  `
1156
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
1270
+ // ── owner scoping (generated) ──${ownerGuardPreamble(
1271
+ ~ownerField=field,
1272
+ ~elevatedGroups,
1273
+ )}`
1157
1274
  }
1158
1275
  let retiredPart = retiredGuardPreamble(
1159
1276
  ~retiredField,
@@ -1356,13 +1473,14 @@ let resolveIds = (
1356
1473
  ~retiredField: option<string>=?,
1357
1474
  ~retiredValues: option<array<string>>=?,
1358
1475
  ~elevatedGroups: array<string>=[],
1359
- ) => (tableName: string) => {
1360
- let keysCode = switch sortField {
1361
- | Some(sf) =>
1362
- `id => ({ id: util.dynamodb.toString(id.id), ${sf}: util.dynamodb.toString(id.${sf}) })`
1363
- | None => `id => ({ id: util.dynamodb.toString(id) })`
1364
- }
1365
- `${importUtil}
1476
+ ) =>
1477
+ (tableName: string) => {
1478
+ let keysCode = switch sortField {
1479
+ | Some(sf) =>
1480
+ `id => ({ id: util.dynamodb.toString(id.id), ${sf}: util.dynamodb.toString(id.${sf}) })`
1481
+ | None => `id => ({ id: util.dynamodb.toString(id) })`
1482
+ }
1483
+ `${importUtil}
1366
1484
  import { runtime } from '@aws-appsync/utils';
1367
1485
  export function request(ctx) {
1368
1486
  const idList = ctx.source.${idsField} ?? [];
@@ -1379,21 +1497,21 @@ export function request(ctx) {
1379
1497
  }
1380
1498
  export function response(ctx) {
1381
1499
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);${switch ownerField {
1382
- | None => ""
1383
- | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1384
- }}${retiredGuardPreamble(
1385
- ~retiredField,
1386
- ~retiredValues,
1387
- ~elevatedGroups,
1388
- ~ownerScoped=ownerField->Option.isSome,
1389
- )}
1500
+ | None => ""
1501
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1502
+ }}${retiredGuardPreamble(
1503
+ ~retiredField,
1504
+ ~retiredValues,
1505
+ ~elevatedGroups,
1506
+ ~ownerScoped=ownerField->Option.isSome,
1507
+ )}
1390
1508
  return (ctx.result?.data?.['${tableName}'] ?? []).filter(item =>
1391
1509
  item !== null${ownerField->Option.isSome ? " && _owns(item)" : ""}${retiredField->Option.isSome
1392
- ? " && _live(item)"
1393
- : ""});
1510
+ ? " && _live(item)"
1511
+ : ""});
1394
1512
  }
1395
1513
  `
1396
- }
1514
+ }
1397
1515
 
1398
1516
  /** Batched-by-ids — reads `ctx.args.ids: [String!]!` through one BatchGetItem.
1399
1517
  Missing ids drop out; empty input short-circuits without hitting DDB. The
@@ -1404,8 +1522,9 @@ let batchGetItemsByIds = (
1404
1522
  ~retiredField: option<string>=?,
1405
1523
  ~retiredValues: option<array<string>>=?,
1406
1524
  ~elevatedGroups: array<string>=[],
1407
- ) => (tableName: string) =>
1408
- `${importUtil}
1525
+ ) =>
1526
+ (tableName: string) =>
1527
+ `${importUtil}
1409
1528
  import { runtime } from '@aws-appsync/utils';
1410
1529
  export function request(ctx) {
1411
1530
  const ids = ctx.args.ids ?? [];
@@ -1430,18 +1549,18 @@ export function response(ctx) {
1430
1549
  // caller does not own is dropped rather than refused, for the reason the
1431
1550
  // single-key door answers null: distinguishing "not yours" from "not there"
1432
1551
  // would make this door an oracle for which ids exist.${switch ownerField {
1433
- | None => ""
1434
- | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1435
- }}${retiredGuardPreamble(
1436
- ~retiredField,
1437
- ~retiredValues,
1438
- ~elevatedGroups,
1439
- ~ownerScoped=ownerField->Option.isSome,
1440
- )}
1552
+ | None => ""
1553
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1554
+ }}${retiredGuardPreamble(
1555
+ ~retiredField,
1556
+ ~retiredValues,
1557
+ ~elevatedGroups,
1558
+ ~ownerScoped=ownerField->Option.isSome,
1559
+ )}
1441
1560
  return (ctx.result?.data?.['${tableName}'] ?? []).filter(item =>
1442
1561
  item !== null${ownerField->Option.isSome ? " && _owns(item)" : ""}${retiredField->Option.isSome
1443
- ? " && _live(item)"
1444
- : ""});
1562
+ ? " && _live(item)"
1563
+ : ""});
1445
1564
  }
1446
1565
  `
1447
1566
 
@@ -1458,37 +1577,43 @@ let refsByIds = (
1458
1577
  ~retiredField: option<string>,
1459
1578
  ~retiredValues: option<array<string>>,
1460
1579
  ~namedWhenRetired: bool,
1580
+ // The picture a reference shows this row by, already read off the state schema
1581
+ // by `Reventless.RowImage` — this template only bakes in the expression it
1582
+ // hands over. `None` for a view that declares none, which emits a literal
1583
+ // `null`, exactly what the nullable field promises.
1584
+ ~imageExpr: option<string>=?,
1461
1585
  ~ownerField: option<string>=?,
1462
1586
  ~elevatedGroups: array<string>=[],
1463
- ) => (tableName: string) => {
1464
- let ownerGuard = switch ownerField {
1465
- // Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
1466
- // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
1467
- // arguments, but got 1") and AppSync rejects the whole resolver at create
1468
- // time with "The code contains one or more errors". Only a door that emits
1469
- // the call conditionally is safe with a bare `() => true`, and that is not a
1470
- // property worth relying on across three templates.
1471
- | None => "\n const _owns = (row) => true;"
1472
- | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1473
- }
1474
- // Retirement, in the vocabulary the row itself uses: a member test for the
1475
- // state form, truthiness for the boolean one. Absent keeps the row live, which
1476
- // is what a row written before the annotation is.
1477
- let retiredExpr = switch (retiredField, retiredValues) {
1478
- | (None, _) => "false"
1479
- | (Some(f), Some(values)) =>
1480
- let literal = values->Array.map(v => `'${v}'`)->Array.join(", ")
1481
- `[${literal}].indexOf(row['${f}']) >= 0`
1482
- | (Some(f), None) => `row['${f}'] === true`
1483
- }
1484
- // Only the state form has a state to name, and only a retired row reports one:
1485
- // this door names rows, it does not publish a lifecycle column to callers the
1486
- // list withholds.
1487
- let stateExpr = switch (retiredField, retiredValues) {
1488
- | (Some(f), Some(_)) => `_retired(row) ? (row['${f}'] ?? null) : null`
1489
- | _ => "null"
1490
- }
1491
- `${importUtil}
1587
+ ) =>
1588
+ (tableName: string) => {
1589
+ let ownerGuard = switch ownerField {
1590
+ // Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
1591
+ // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
1592
+ // arguments, but got 1") and AppSync rejects the whole resolver at create
1593
+ // time with "The code contains one or more errors". Only a door that emits
1594
+ // the call conditionally is safe with a bare `() => true`, and that is not a
1595
+ // property worth relying on across three templates.
1596
+ | None => "\n const _owns = (row) => true;"
1597
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1598
+ }
1599
+ // Retirement, in the vocabulary the row itself uses: a member test for the
1600
+ // state form, truthiness for the boolean one. Absent keeps the row live, which
1601
+ // is what a row written before the annotation is.
1602
+ let retiredExpr = switch (retiredField, retiredValues) {
1603
+ | (None, _) => "false"
1604
+ | (Some(f), Some(values)) =>
1605
+ let literal = values->Array.map(v => `'${v}'`)->Array.join(", ")
1606
+ `[${literal}].indexOf(row['${f}']) >= 0`
1607
+ | (Some(f), None) => `row['${f}'] === true`
1608
+ }
1609
+ // Only the state form has a state to name, and only a retired row reports one:
1610
+ // this door names rows, it does not publish a lifecycle column to callers the
1611
+ // list withholds.
1612
+ let stateExpr = switch (retiredField, retiredValues) {
1613
+ | (Some(f), Some(_)) => `_retired(row) ? (row['${f}'] ?? null) : null`
1614
+ | _ => "null"
1615
+ }
1616
+ `${importUtil}
1492
1617
  import { runtime } from '@aws-appsync/utils';
1493
1618
  export function request(ctx) {
1494
1619
  const ids = ctx.args.ids ?? [];
@@ -1513,19 +1638,19 @@ export function response(ctx) {
1513
1638
  .map(row => ({
1514
1639
  id: row.id,
1515
1640
  label: row['${labelField}'] ?? row.id,
1641
+ image: ${imageExpr->Option.getOr("null")},
1516
1642
  retired: _retired(row),
1517
1643
  retiredState: ${stateExpr},
1518
1644
  }));
1519
1645
  }
1520
1646
  `
1521
- }
1647
+ }
1522
1648
 
1523
1649
  // ---------------------------------------------------------------------------
1524
1650
  // DynamoDB write
1525
1651
  // ---------------------------------------------------------------------------
1526
1652
 
1527
- let putItem =
1528
- `${importUtil}
1653
+ let putItem = `${importUtil}
1529
1654
  export function request(ctx) {
1530
1655
  return {
1531
1656
  operation: 'PutItem',
@@ -1552,8 +1677,7 @@ export function request(ctx) {
1552
1677
  ${resultResponseCode}
1553
1678
  `->Pulumi.Input.make
1554
1679
 
1555
- let deleteItem =
1556
- `${importUtil}
1680
+ let deleteItem = `${importUtil}
1557
1681
  export function request(ctx) {
1558
1682
  return {
1559
1683
  operation: 'DeleteItem',
@@ -1567,6 +1691,24 @@ ${resultResponseCode}
1567
1691
  // Lambda invocations
1568
1692
  // ---------------------------------------------------------------------------
1569
1693
 
1694
+ // The caller as a command Lambda reads it: a Cognito user, an IAM principal, or null.
1695
+ let callerIdentity = `identity: isCognito
1696
+ ? {
1697
+ userId: id.sub,
1698
+ username: id.username,
1699
+ groups: id.claims?.['cognito:groups'] ?? [],
1700
+ claims: id.claims,
1701
+ provider: 'Cognito'
1702
+ }
1703
+ : id != null
1704
+ ? {
1705
+ userArn: id.userArn ?? null,
1706
+ accountId: id.accountId ?? null,
1707
+ username: id.username ?? null,
1708
+ provider: 'IAM'
1709
+ }
1710
+ : null`
1711
+
1570
1712
  /** Invoke a CommandGenerator Lambda. `command` is the Aggregate command name. */
1571
1713
  let invokeCommandGenerator = (command: string) =>
1572
1714
  `${importUtil}
@@ -1579,26 +1721,10 @@ export function request(ctx) {
1579
1721
  command: '${command}',
1580
1722
  arguments: ctx.args,
1581
1723
  meta: {
1582
- ip: id?.sourceIp ?? null,
1583
1724
  user: id?.username ?? null,
1584
1725
  info: ctx.info.parentTypeName + '.' + ctx.info.fieldName
1585
1726
  },
1586
- identity: isCognito
1587
- ? {
1588
- userId: id.sub,
1589
- username: id.username,
1590
- groups: id.claims?.['cognito:groups'] ?? [],
1591
- claims: id.claims,
1592
- provider: 'Cognito'
1593
- }
1594
- : id != null
1595
- ? {
1596
- userArn: id.userArn ?? null,
1597
- accountId: id.accountId ?? null,
1598
- username: id.username ?? null,
1599
- provider: 'IAM'
1600
- }
1601
- : null
1727
+ ${callerIdentity}
1602
1728
  }
1603
1729
  };
1604
1730
  }
@@ -1617,26 +1743,10 @@ export function request(ctx) {
1617
1743
  command: '${tag}',
1618
1744
  arguments: ctx.args,
1619
1745
  meta: {
1620
- ip: id?.sourceIp ?? null,
1621
1746
  user: id?.username ?? null,
1622
1747
  info: ctx.info.parentTypeName + '.' + ctx.info.fieldName
1623
1748
  },
1624
- identity: isCognito
1625
- ? {
1626
- userId: id.sub,
1627
- username: id.username,
1628
- groups: id.claims?.['cognito:groups'] ?? [],
1629
- claims: id.claims,
1630
- provider: 'Cognito'
1631
- }
1632
- : id != null
1633
- ? {
1634
- userArn: id.userArn ?? null,
1635
- accountId: id.accountId ?? null,
1636
- username: id.username ?? null,
1637
- provider: 'IAM'
1638
- }
1639
- : null
1749
+ ${callerIdentity}
1640
1750
  }
1641
1751
  };
1642
1752
  }
@@ -1647,12 +1757,15 @@ ${resultResponseCode}
1647
1757
  let invokeInboundTranslation = (fieldName: string) =>
1648
1758
  `${importUtil}
1649
1759
  export function request(ctx) {
1760
+ const id = ctx.identity;
1761
+ const isCognito = id != null && id.sub != null;
1650
1762
  return {
1651
1763
  operation: 'Invoke',
1652
1764
  payload: {
1653
1765
  __inboundTranslation: true,
1654
1766
  fieldName: '${fieldName}',
1655
- arguments: ctx.args
1767
+ arguments: ctx.args,
1768
+ ${callerIdentity}
1656
1769
  }
1657
1770
  };
1658
1771
  }
@@ -1696,8 +1809,7 @@ export function response(ctx) {
1696
1809
  let authorizeIndexedAccessRequest = authorizeIndexedAccess
1697
1810
 
1698
1811
  /** @deprecated The response check is now part of authorizeIndexedAccess. */
1699
- let authorizeIndexedAccessResponse = (~group as _: string) =>
1700
- resultResponseCode->Pulumi.Input.make
1812
+ let authorizeIndexedAccessResponse = (~group as _: string) => resultResponseCode->Pulumi.Input.make
1701
1813
 
1702
1814
  // ---------------------------------------------------------------------------
1703
1815
  // Response-only helpers