@reventlessdev/rescript-pulumi-aws 3.0.0-alpha.2 → 3.0.0-alpha.20

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 (33) hide show
  1. package/CHANGELOG.md +152 -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 +495 -363
  6. package/src/AppSync/AppSync_Resolver_Functions.res.mjs +182 -97
  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 +0 -1
  15. package/src/Cognito/Cognito_UserPool.res +2 -4
  16. package/src/DynamoDb/DynamoDb_Table.res +23 -0
  17. package/src/DynamoDb/DynamoDb_Table.res.mjs +8 -1
  18. package/src/IAM/IAM.res +1 -1
  19. package/src/IAM/PolicyDocument.res +2 -2
  20. package/src/IAM/PolicyDocument.res.mjs +3 -3
  21. package/src/Kinesis/Kinesis_Stream.res +3 -6
  22. package/src/Lambda/FunctionUrl.res +5 -2
  23. package/src/Lambda/Lambda.res +134 -52
  24. package/src/Lambda/Lambda.res.mjs +152 -23
  25. package/src/Rds/Rds_Cluster.res +0 -1
  26. package/src/Rds/Rds_Instance.res +0 -1
  27. package/src/Rds/Rds_Proxy.res +1 -2
  28. package/src/S3/S3_BucketV2.res +1 -3
  29. package/src/example/AlarmMailExample.res +34 -0
  30. package/src/example/AlarmMailExample.res.mjs +44 -0
  31. package/src/example/CustomDomainExample.res +2 -3
  32. package/src/example/MergedApiExample.res +1 -3
  33. package/tests/AppSync_Resolver_FunctionsTest.mjs +368 -26
@@ -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;
@@ -30,19 +29,11 @@ export function response(ctx) {
30
29
  let importUtil = `import { util } from '@aws-appsync/utils';`
31
30
 
32
31
  /**
33
- The caller-is-exempt test, emitted into a resolver's response.
34
-
35
- Mirrors `Reventless.OwnerScope.resolve` — the same branch order for the same
36
- reason the list predicate gives: an IAM-signed service caller has no `sub`
37
- because it is inside the trust boundary, not because it is anonymous, so the
38
- provider question is answered before the identity one.
39
-
40
- In the RESPONSE rather than the request, because a `GetItem` has no
41
- FilterExpression to carry a predicate: the row is fetched by key and the
42
- decision is made on what came back. A `Query` could filter server-side, and
43
- deliberately does not — a single-row read that filtered in one place and
44
- guarded in another would have two implementations of one rule, and the cheaper
45
- one is the one nobody would remember to change.
32
+ The caller-is-exempt test, emitted into a resolver's response. Mirrors
33
+ `Reventless.OwnerScope.resolve`, branch order included — an IAM-signed caller has
34
+ no `sub` because it is inside the trust boundary, not because it is anonymous.
35
+ In the response because `GetItem` has no FilterExpression; `Query` follows it so
36
+ one rule keeps one implementation.
46
37
  */
47
38
  let ownerGuardPreamble = (~ownerField: string, ~elevatedGroups: array<string>) => {
48
39
  let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
@@ -56,12 +47,9 @@ let ownerGuardPreamble = (~ownerField: string, ~elevatedGroups: array<string>) =
56
47
  }
57
48
 
58
49
  /**
59
- The exemption test on its own, for a door that narrows retirement but has no
60
- `@owner` field to have declared it already.
61
-
62
- Identical to the three lines `ownerGuardPreamble` opens with, and emitted only
63
- when that preamble is absent — two `const _exempt` in one function body is a
64
- syntax error, and two *different* definitions of exempt would be worse than one.
50
+ The exemption test alone, for a door that narrows retirement but declares no
51
+ `@owner`. Emitted only when `ownerGuardPreamble` is absent — two `const _exempt`
52
+ in one body is a syntax error, and two different ones would be worse.
65
53
  */
66
54
  let exemptPreamble = (~elevatedGroups: array<string>) => {
67
55
  let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
@@ -75,22 +63,14 @@ let exemptPreamble = (~elevatedGroups: array<string>) => {
75
63
 
76
64
  /**
77
65
  A by-key read's retirement guard: `_live(row)`, true when the caller may see it.
66
+ The post-read half of what `listAllItemsConnection` pushes into a
67
+ FilterExpression — `GetItem` has none to push into.
78
68
 
79
- The post-read half of the predicate `listAllItemsConnection` pushes into a
80
- FilterExpression. `GetItem` and `BatchGetItem` have no filter to push into — the
81
- row is fetched by key — so the decision is made on what came back, which is where
82
- the owner guard beside it already makes its own.
83
-
84
- `row[field] == null` keeps a row written before the annotation existed, matching
85
- the `attribute_not_exists` half of the list's clause and every other adapter's
86
- reading of an absent value.
87
-
88
- Asking is required, not merely permitted: `_exempt` alone leaves a retired row
89
- withheld until `includeRetired` is passed, so an operator's ordinary read is as
90
- narrow as anyone's. An archive that is always underfoot is not an archive.
69
+ `row[field] == null` keeps a row written before the annotation existed. Exemption
70
+ alone withholds a retired row until `includeRetired` is passed, so an operator's
71
+ ordinary read is as narrow as anyone's.
91
72
 
92
- `~ownerScoped` says whether an `ownerGuardPreamble` has already been emitted into
93
- the same body; when it has, this reuses its `_exempt` instead of redeclaring one.
73
+ `~ownerScoped` says an `ownerGuardPreamble` already declared `_exempt` here.
94
74
  */
95
75
  let retiredGuardPreamble = (
96
76
  ~retiredField: option<string>,
@@ -116,23 +96,15 @@ let retiredGuardPreamble = (
116
96
  }
117
97
 
118
98
  /**
119
- The `@owner` predicate for an index door, as a FilterExpression clause.
120
-
121
- Same rule and same branch order as `listAllItemsConnection`'s own owner clause —
122
- provider before identity, because an IAM-signed service caller has no `sub` for a
123
- reason that has nothing to do with being anonymous — and pushed into the read for
124
- the same reason: an index door takes a `limit`, and a page cut before the
125
- predicate would come back short with nothing said about why.
126
-
127
- **Not applied to a group-restricted index.** Where `indexConfig.authorization` is
128
- set, the door already runs `authorizeIndexedAccess`: the caller must be in the
129
- named group AND be the holder the auth table records for that index value. Those
130
- rows are, by construction, other people's — an order assigned to a fulfilment
131
- operator is owned by the customer who placed it — so ANDing `@owner` on top would
132
- return nothing and revoke exactly the access the auth table was written to grant.
133
- An explicit per-index rule is the deployment's answer for that door; this is the
134
- default for doors that have none. `QueryDbResolvers_AppSync` decides which is
135
- which and passes `ownerField` only for the latter.
99
+ The `@owner` predicate for an index door, as a FilterExpression clause. Same rule
100
+ and branch order as `listAllItemsConnection`'s, and pushed into the read for the
101
+ same reason.
102
+
103
+ **Not applied to a group-restricted index.** There `authorizeIndexedAccess`
104
+ already gates the caller, and the rows are by construction other people's — an
105
+ order assigned to a fulfilment operator is owned by the customer who placed it —
106
+ so ANDing `@owner` on top would revoke exactly what the auth table granted.
107
+ `QueryDbResolvers_AppSync` passes `ownerField` only for doors with no such rule.
136
108
  */
137
109
  let ownerFilterClause = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
138
110
  switch ownerField {
@@ -158,16 +130,12 @@ let ownerFilterClause = (~ownerField: option<string>, ~elevatedGroups: array<str
158
130
 
159
131
  /**
160
132
  The same retirement predicate as `retiredGuardPreamble`, for a door that reads
161
- with `Query` and therefore has a FilterExpression to put it in.
133
+ with `Query` and so has a FilterExpression to put it in. Pushed into the read
134
+ rather than applied after it, or the page comes back short with nothing said
135
+ about why.
162
136
 
163
- Pushed into the read rather than applied to what came back, on
164
- `listAllItemsConnection`'s reasoning: an index door takes a `limit`, and
165
- narrowing after the read would hand back a page of fewer rows than the caller
166
- asked for while reporting nothing about why.
167
-
168
- Emits JS that appends to the `expression` / `names` / `values` the index
169
- templates already build, so it composes with a caller's own filter arguments
170
- instead of replacing them.
137
+ Appends to the `expression` / `names` / `values` the index templates already
138
+ build, so it composes with a caller's own filter arguments.
171
139
  */
172
140
  let retiredFilterClause = (
173
141
  ~retiredField: option<string>,
@@ -207,13 +175,9 @@ ${assignments}
207
175
  /**
208
176
  A by-key read's response, refusing a row the caller does not own.
209
177
 
210
- **Null, not an error** — which is the opposite of what a first reading suggests,
211
- since "you may not read this" and "there is nothing here" are different answers
212
- and only one of them is true. Two things settle it. The in-process platform
213
- already answers `null` here, and a rule enforced differently per transport is
214
- the failure mode owner scoping exists to avoid. And an error would confirm the
215
- row exists to a caller who may not read it, which is a worse leak than the
216
- ambiguity it removes.
178
+ **Null, not an error.** The in-process platform answers `null` here, and a rule
179
+ enforced differently per transport is what owner scoping exists to avoid. An
180
+ error would also confirm the row exists to a caller who may not read it.
217
181
  */
218
182
  let ownerScopedResultResponse = (
219
183
  ~ownerField: option<string>,
@@ -226,15 +190,18 @@ let ownerScopedResultResponse = (
226
190
  | _ =>
227
191
  let ownerPart = switch ownerField {
228
192
  // 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;"
193
+ // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
194
+ // arguments, but got 1") and AppSync rejects the whole resolver at create
195
+ // time with "The code contains one or more errors". Only a door that emits
196
+ // the call conditionally is safe with a bare `() => true`, and that is not a
197
+ // property worth relying on across three templates.
198
+ | None => "\n const _owns = (row) => true;"
235
199
  | Some(field) =>
236
200
  `
237
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
201
+ // ── owner scoping (generated) ──${ownerGuardPreamble(
202
+ ~ownerField=field,
203
+ ~elevatedGroups,
204
+ )}`
238
205
  }
239
206
  let retiredPart = retiredGuardPreamble(
240
207
  ~retiredField,
@@ -245,7 +212,9 @@ let ownerScopedResultResponse = (
245
212
  `
246
213
  export function response(ctx) {
247
214
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
248
- return _owns(ctx.result)${retiredField->Option.isSome ? " && _live(ctx.result)" : ""} ? ctx.result : null;
215
+ return _owns(ctx.result)${retiredField->Option.isSome
216
+ ? " && _live(ctx.result)"
217
+ : ""} ? ctx.result : null;
249
218
  }`
250
219
  }
251
220
 
@@ -261,15 +230,18 @@ let ownerScopedFirstResultResponse = (
261
230
  | _ =>
262
231
  let ownerPart = switch ownerField {
263
232
  // Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
264
- // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
265
- // arguments, but got 1") and AppSync rejects the whole resolver at create
266
- // time with "The code contains one or more errors". Only a door that emits
267
- // the call conditionally is safe with a bare `() => true`, and that is not a
268
- // property worth relying on across three templates.
269
- | None => "\n const _owns = (row) => true;"
233
+ // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
234
+ // arguments, but got 1") and AppSync rejects the whole resolver at create
235
+ // time with "The code contains one or more errors". Only a door that emits
236
+ // the call conditionally is safe with a bare `() => true`, and that is not a
237
+ // property worth relying on across three templates.
238
+ | None => "\n const _owns = (row) => true;"
270
239
  | Some(field) =>
271
240
  `
272
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
241
+ // ── owner scoping (generated) ──${ownerGuardPreamble(
242
+ ~ownerField=field,
243
+ ~elevatedGroups,
244
+ )}`
273
245
  }
274
246
  let retiredPart = retiredGuardPreamble(
275
247
  ~retiredField,
@@ -290,8 +262,7 @@ export function response(ctx) {
290
262
  // ---------------------------------------------------------------------------
291
263
 
292
264
  /** Pipeline resolver code with no pre-processing and a standard error-check response. */
293
- let pipelinePassThrough =
294
- `${importUtil}
265
+ let pipelinePassThrough = `${importUtil}
295
266
  export function request(ctx) { return {}; }
296
267
  ${resultResponseCode}
297
268
  `->Pulumi.Input.make
@@ -303,12 +274,9 @@ ${resultResponseCode}
303
274
  /** Pipeline function (NONE datasource): decodes global ID and stashes typeName + localId.
304
275
 
305
276
  Written without a `try`: APPSYNC_JS rejects try statements outright
306
- (`@aws-appsync/no-try`), so a guarded version of this cannot be deployed at
307
- all. Nothing is lost by dropping it — `util.base64Decode` does not throw on
308
- malformed input, it returns the bytes it made of it, so the catch could never
309
- run. An id that decodes to no `type:localId` pair therefore fails the one way
310
- that is left: both halves stash as null, which is also what the guarded
311
- version reported for a decode it could not parse. */
277
+ (`@aws-appsync/no-try`), and nothing is lost — `util.base64Decode` returns
278
+ bytes rather than throwing on malformed input, so an unparseable id stashes
279
+ both halves as null, which is what a guarded version reported anyway. */
312
280
  let nodeDecodeGlobalId =
313
281
  `${importUtil}
314
282
  export function request(ctx) {
@@ -367,8 +335,7 @@ export function request(ctx) {
367
335
  ${ownerScopedResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
368
336
  `->Pulumi.Input.make
369
337
 
370
- let queryById =
371
- `${importUtil}
338
+ let queryById = `${importUtil}
372
339
  export function request(ctx) {
373
340
  return {
374
341
  operation: 'Query',
@@ -386,11 +353,11 @@ ${resultResponseCode}
386
353
  Relay pagination: `first`/`after` (forward) or `last`/`before` (backward).
387
354
  Cursor is base64 of the sort key value.
388
355
  Returns a Relay `{ edges, pageInfo }` shape reusing the entity's `Connection` type. */
389
- // A list in everything but its name, so it scopes the way `listAllItemsConnection`
356
+ let // A list in everything but its name, so it scopes the way `listAllItemsConnection`
390
357
  // does — a FilterExpression on the request, not a guard on the response. The
391
358
  // response is where the page is cut, and narrowing after that cut would report
392
359
  // `hasNextPage` from a count the caller was never allowed to see.
393
- let queryItemsWithSortConditions = (
360
+ queryItemsWithSortConditions = (
394
361
  sortField: string,
395
362
  ~ownerField: option<string>=?,
396
363
  ~elevatedGroups: array<string>=[],
@@ -585,61 +552,191 @@ export function request(ctx) {
585
552
  ${resultResponseCode}
586
553
  `->Pulumi.Input.make
587
554
 
555
+ // ---------------------------------------------------------------------------
556
+ // Paging a filtered read
557
+ // ---------------------------------------------------------------------------
558
+
588
559
  /**
589
- The by-index door's response.
560
+ Rows a read may EXAMINE per page.
590
561
 
591
- Cursors are the DynamoDB continuation token carrying the row's position in the
592
- page, exactly as `listAllItemsConnection` builds them — the two doors page over
593
- the same kind of result, so they page the same way. The boundary cursor covers
594
- the case that door documents: a filtered page can come back empty while a token
595
- is still set, and a client has to be able to resume past it.
562
+ `Limit` applies before the FilterExpression, so reading `first` rows and returning
563
+ the survivors serves short — usually empty — pages under a selective filter. With
564
+ no loops in APPSYNC_JS the door reads wider instead and addresses the surplus by
565
+ position. 1 MB caps a page anyway, hence 1000; `filtered` is the JS expression
566
+ saying whether a filter was pushed down.
567
+ */
568
+ let pageWindowBudget = (~filtered: string) =>
569
+ `(${filtered} ? (_first > 1000 ? _first : 1000) : _first + _from)`
596
570
 
597
- This replaces returning `ctx.result` raw. The field has always been declared as
598
- returning a `Connection!`, and handing back DynamoDB's `{items, nextToken}`
599
- satisfied no part of that contract.
571
+ /**
572
+ The same budget for the full-list door, which may also read backward.
573
+
574
+ A backward page is the slice `[_from, _upTo)` of the window, so the read has to
575
+ reach `_upTo` rows into it — `_first + _from` would stop short and hand back a
576
+ page with its tail missing.
600
577
  */
601
- let indexConnectionResponseCode = `
602
- export function response(ctx) {
603
- if (ctx.error) util.error(ctx.error.message, ctx.error.type);
604
- const items = ctx.result?.items ?? [];
605
- const next = ctx.result?.nextToken ?? null;
606
- const edges = items.map((item, i) => ({
578
+ let listPageWindowBudget = {
579
+ let need = "(_backward ? _upTo : _first + _from)"
580
+ `(parts.length > 0 ? (${need} > 1000 ? ${need} : 1000) : ${need})`
581
+ }
582
+
583
+ /**
584
+ Decodes `after` into the window it names (`t`, the token opening it) and the row's
585
+ index among that window's matches (`n`). Pre-window cursors carried
586
+ `{token, index}` naming the window that follows — position -1 of it.
587
+
588
+ `p` is the one-character tag naming which read the window belongs to (`s` Scan,
589
+ `q` the owner-index Query). Absent reads as `s`: every cursor minted before the
590
+ tag existed came off a Scan. Only a door with more than one read tests it — see
591
+ `cursorPathGuard`.
592
+ */
593
+ let cursorDecode = (~args: string) =>
594
+ `
595
+ let _window = null;
596
+ let _from = 0;
597
+ let _cursorPath = null;
598
+ if (${args}.after != null && ${args}.after !== '') {
599
+ const _c = JSON.parse(util.base64Decode(${args}.after));
600
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
601
+ _from = _c.n !== undefined ? _c.n + 1 : 0;
602
+ _cursorPath = _c.p ?? 's';
603
+ }`
604
+
605
+ /**
606
+ Refuses a cursor minted on this door's other read. The two branches are selectable
607
+ by the SAME caller across requests — an active-role switch mid-pagination flips
608
+ `_exempt` — and a `nextToken` continues the operation that issued it, so replaying
609
+ one on the other branch answers a different question without saying so.
610
+
611
+ Expects `_cursorPath` from `cursorDecode` and `_exempt` from the owner preamble.
612
+ */
613
+ let cursorPathGuard = `
614
+ if (_cursorPath !== null && _cursorPath !== (_exempt ? 's' : 'q')) {
615
+ util.error('This cursor belongs to a different read of this list; restart from the first page.', 'CursorPathMismatch');
616
+ }`
617
+
618
+ /**
619
+ The full-list door's cursor decode, which unlike `cursorDecode` may run backward.
620
+
621
+ A `{t, n}` cursor names a position INSIDE a read window, and a window is re-read
622
+ from its own token — which forward paging already relies on to resume mid-window.
623
+ Backward is the same move in the other direction: re-read the window `before`
624
+ names and cut the page that ends at `n`, `[n - first, n)`. No new cursor shape, no
625
+ index, and no assumption forward paging does not already make.
626
+
627
+ What it cannot do is cross into an EARLIER window: DynamoDB's continuation chain
628
+ walks one way, so a window cannot name the one before it. `_upTo <= 0` with a
629
+ non-null window is that case, and it is the only one still refused.
630
+
631
+ Declares `_first` (the response's slicing needs it before `connectionPageResponse`
632
+ would have declared it, so that one omits it here).
633
+ */
634
+ let listCursorPreamble = `
635
+ const _first = ctx.args.first ?? 50;
636
+ let _window = null;
637
+ let _from = 0;
638
+ let _cursorPath = null;
639
+ let _backward = false;
640
+ let _upTo = -1;
641
+ if (ctx.args.before != null && ctx.args.before !== '') {
642
+ const _c = JSON.parse(util.base64Decode(ctx.args.before));
643
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
644
+ _cursorPath = _c.p ?? 's';
645
+ _backward = true;
646
+ _upTo = _c.n !== undefined ? _c.n : 0;
647
+ _from = (_upTo - _first) > 0 ? (_upTo - _first) : 0;
648
+ } else if (ctx.args.after != null && ctx.args.after !== '') {
649
+ const _c = JSON.parse(util.base64Decode(ctx.args.after));
650
+ _window = (_c.t !== undefined ? _c.t : _c.token) ?? null;
651
+ _from = _c.n !== undefined ? _c.n + 1 : 0;
652
+ _cursorPath = _c.p ?? 's';
653
+ }`
654
+
655
+ /**
656
+ Cuts the requested page out of the returned window. Expects `items` (sorted, if the
657
+ door sorts) plus `_window` / `_from` from `cursorDecode`. `pathExpr` is the JS
658
+ expression naming which read minted these cursors, for a door that has two.
659
+
660
+ `bidirectional` is the full-list door, whose page may have been cut backward: it
661
+ reads `_backward` / `_upTo` from `listCursorPreamble` and takes `_first` from
662
+ there rather than declaring its own.
663
+ */
664
+ let connectionPageResponse = (~pathExpr: option<string>=?, ~bidirectional: bool=false) => {
665
+ let tag = switch pathExpr {
666
+ | None => ""
667
+ | Some(e) => `, p: ${e}`
668
+ }
669
+ let firstDecl = bidirectional ? "" : "\n const _first = ctx.args.first ?? 50;"
670
+ let slice = bidirectional
671
+ ? `
672
+ const _rest = _backward ? items.slice(_from, _upTo) : items.slice(_from);
673
+ const _page = _backward ? _rest : _rest.slice(0, _first);
674
+ // A backward page was cut from ahead, so a next page provably exists — the one
675
+ // the caller came from — whatever this window's tail looks like.
676
+ const _more = _backward ? true : _rest.length > _first;`
677
+ : `
678
+ const _rest = items.slice(_from);
679
+ const _page = _rest.slice(0, _first);
680
+ const _more = _rest.length > _first;`
681
+ // Only what the door can actually serve. `!!ctx.args.after` said "a page
682
+ // precedes this one", which is a different claim: at a window boundary one does
683
+ // and this door cannot reach it, so the client drew a Prev that always errored.
684
+ let hasPrevious = bidirectional ? "_from > 0" : "!!ctx.args.after"
685
+ `${firstDecl}${slice}
686
+ const _next = ctx.result?.nextToken ?? null;
687
+ const _lastIndex = _page.length - 1;
688
+ // A row's cursor names its own position. The last row of a page that closes its
689
+ // window is the exception — no position follows it there, so it names the next
690
+ // window, or resuming from it answers blank.
691
+ const edges = _page.map((item, i) => ({
607
692
  node: item,
608
- cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
693
+ cursor: util.base64Encode(JSON.stringify(
694
+ (!_more && _next && i === _lastIndex)
695
+ ? { t: _next, n: -1${tag} }
696
+ : { t: _window, n: _from + i${tag} }
697
+ )),
609
698
  }));
610
- const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
699
+ // A window the filter emptied leaves no row to cut a cursor from; the token is
700
+ // the window's, so a client can step past it rather than restart.
701
+ const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1${tag} })) : null;
611
702
  return {
612
703
  edges,
613
704
  pageInfo: {
614
- hasNextPage: !!next,
615
- hasPreviousPage: !!ctx.args.after,
616
- startCursor: edges.length > 0 ? edges[0].cursor : boundary,
617
- endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
705
+ hasNextPage: _more || !!_next,
706
+ hasPreviousPage: ${hasPrevious},
707
+ startCursor: edges.length > 0 ? edges[0].cursor : _boundary,
708
+ endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : _boundary,
618
709
  },
619
- };
710
+ };`
711
+ }
712
+
713
+ /**
714
+ The by-index door's response. Pages out of a read window exactly as
715
+ `listAllItemsConnection` does, and satisfies the `Connection!` the field has always
716
+ declared — returning `ctx.result` raw did not.
717
+ */
718
+ let indexConnectionResponseCode = `
719
+ export function response(ctx) {
720
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
721
+ const items = ctx.result?.items ?? [];${cursorDecode(~args="ctx.args")}${connectionPageResponse()}
620
722
  }`
621
723
 
622
724
  /** Refuses backward paging, for the reason `listAllItemsConnection` gives: the
623
725
  cursor is DynamoDB's own continuation token, which only walks forward, so
624
- `last`/`before` cannot be honoured and handing back the forward page would answer
625
- a different question without saying so.
626
-
627
- The arguments stay declared — one that came and went with the index's shape would
628
- make every client feature-detect — and the local backend refuses them with the
629
- same message, so the door reads the same either side of a deploy. */
726
+ `last`/`before` cannot be honoured, and handing back the forward page would answer
727
+ a different question without saying so. The arguments stay declared — one that
728
+ came and went with the index's shape would make every client feature-detect — and
729
+ the local backend refuses them with the same message. */
630
730
  let indexBackwardPagingGuard = `
631
731
  if (args.before != null || args.last != null) {
632
732
  util.error('Backward pagination (last/before) is not supported on by-index connections; use first/after.', 'UnsupportedPagination');
633
733
  }`
634
734
 
635
- /** Decodes the Relay `after` cursor back to the DynamoDB continuation token the
636
- response side encoded. Mirrors `listAllItemsConnection`'s request half. */
637
- let indexCursorPreamble = `
638
- let after = null;
639
- if (args.after != null && args.after !== '') {
640
- const parsed = JSON.parse(util.base64Decode(args.after));
641
- after = parsed.token ?? null;
642
- }`
735
+ /** Decodes the Relay `after` cursor back to the read window the response side
736
+ encoded, and sizes the window this read may examine. Mirrors
737
+ `listAllItemsConnection`'s request half. */
738
+ let indexCursorPreamble = `${cursorDecode(~args="args")}
739
+ const _first = args.first ?? 50;`
643
740
 
644
741
  // The arguments the by-index door declares, none of which is a column to match
645
742
  // on. `includeRetired` is a request to lift a restriction and the rest are
@@ -688,13 +785,17 @@ export function request(ctx) {
688
785
  values[':' + key] = '' + value;
689
786
  }
690
787
  });
691
- ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
788
+ ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(
789
+ ~retiredField,
790
+ ~retiredValues,
791
+ ~elevatedGroups,
792
+ )}${indexCursorPreamble}
692
793
  const result = {
693
794
  operation: 'Query',
694
795
  query,
695
796
  index: '${index}',
696
- limit: (args.first ?? 50),
697
- nextToken: after,
797
+ limit: ${pageWindowBudget(~filtered="expression")},
798
+ nextToken: _window,
698
799
  scanIndexForward: (args.forward ?? true)
699
800
  };
700
801
  if (expression) {
@@ -751,13 +852,17 @@ export function request(ctx) {
751
852
  values[':' + key] = '' + value;
752
853
  }
753
854
  });
754
- ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
855
+ ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(
856
+ ~retiredField,
857
+ ~retiredValues,
858
+ ~elevatedGroups,
859
+ )}${indexCursorPreamble}
755
860
  const result = {
756
861
  operation: 'Query',
757
862
  query,
758
863
  index: '${index}',
759
- limit: (args.first ?? 50),
760
- nextToken: after,
864
+ limit: ${pageWindowBudget(~filtered="expression")},
865
+ nextToken: _window,
761
866
  scanIndexForward: (args.forward ?? true)
762
867
  };
763
868
  if (expression) {
@@ -772,8 +877,7 @@ ${indexConnectionResponseCode}
772
877
  // DynamoDB read — list all
773
878
  // ---------------------------------------------------------------------------
774
879
 
775
- let listAllItems =
776
- `${importUtil}
880
+ let listAllItems = `${importUtil}
777
881
  export function request(ctx) {
778
882
  return {
779
883
  operation: 'Scan',
@@ -789,48 +893,36 @@ ${resultResponseCode}
789
893
  // ---------------------------------------------------------------------------
790
894
 
791
895
  /**
792
- * Scan with optional `filter` arg: `{search?, searchPrefix?, ids?, <field>Eq?, <field>From?, <field>To?}`
793
- * and optional `orderBy: {field, direction}`.
794
- *
795
- * `search` → `contains(labelField, :v)` — case-sensitive on DynamoDB. Callers that
796
- * need case-insensitive matching should project a lowercased label column
797
- * (future Phase 6.1 / external full-text search).
798
- * `searchPrefix` → `begins_with(labelField, :v)` — case-sensitive. Scan-only here;
799
- * Phase 6 `@searchable` provisions a GSI to promote this to a query.
800
- * `ids` → FilterExpression `#id IN (:id0, :id1, …)`. Simple scan-based
801
- * path; BatchGetItem optimisation is deferred (open question 1).
802
- * `<field>Eq` → FilterExpression `#<field> = :<field>Eq`, one per `~filterFields` entry.
803
- * `<field>From` → FilterExpression `#<field> >= :<field>From`, one per `~rangeFields` entry.
804
- * `<field>To` → FilterExpression `#<field> <= :<field>To`, one per `~rangeFields` entry.
805
- * `orderBy` → JS-runtime sort over the returned page when `orderBy.field` is in
806
- * `~sortFields`. **Per-page only**, not global — DynamoDB Scan returns
807
- * items in indeterminate order and `ScanIndexForward` does not apply
808
- * to Scan. Index-routed Query (v1.5) lifts this caveat for indexed
809
- * sort fields; `@scanSort` on a non-indexed field is per-page even then.
810
- *
811
- * Empty-string and null filter values are treated as "no filter" — consistent with the
812
- * in-memory adapter so clients don't need to conditionally omit keys.
813
- */
896
+ Scan behind `filter: {search?, searchPrefix?, ids?, <field>Eq?, <field>From?,
897
+ <field>To?}` and `orderBy: {field, direction}`. `search` / `searchPrefix` become
898
+ `contains` / `begins_with` on `labelField` (case-sensitive — a case-insensitive
899
+ match wants a lowercased projected column); `ids` becomes `#id IN (…)`; the
900
+ per-field forms become `=` / `>=` / `<=`.
901
+
902
+ `orderBy` sorts in the JS runtime over the read window, not globally: Scan returns
903
+ items in indeterminate order and `ScanIndexForward` is Query-only. Empty and null
904
+ filter values mean "no filter", as they do in-memory.
905
+
906
+ With `ownerIndex` the door has **two** reads against the same data source, chosen
907
+ by whether the caller is exempt from owner scoping: a Query on the derived
908
+ `@owner` index for a scoped caller, the Scan above for everyone else. A user
909
+ `filter` still lands in a FilterExpression on top of the Query's key condition, so
910
+ a scoped caller searching their own rows can still get a short page — but bounded
911
+ by their row count rather than the table's.
912
+ */
814
913
  let listAllItemsConnection = (
815
914
  ~labelField: string,
816
915
  ~filterFields: array<string>=[],
817
916
  ~rangeFields: array<string>=[],
818
917
  ~sortFields: array<string>=[],
819
- // When set, emit an always-on `attribute_exists(#<attr>)` FilterExpression clause
820
- // (ANDed with any client filters) so rows lacking that attribute never enter the
821
- // Connection. Used for read models whose physical DynamoDB table co-hosts internal
822
- // bookkeeping rows written outside the projection (e.g. the Plugins admin RM, whose
823
- // table also holds `deploy-schema:*` / `plugin-info:*` rows with no `name`). Those
824
- // rows would otherwise resolve `name`/`status`/`version` to null and violate the
825
- // non-null GraphQL connection schema, nulling the whole connection.
918
+ // An always-on `attribute_exists(#<attr>)` clause, for a read model whose table
919
+ // co-hosts bookkeeping rows written outside the projection (the Plugins admin RM's
920
+ // `deploy-schema:*` / `plugin-info:*` rows carry no `name`). Those rows resolve
921
+ // non-null fields to null and take the whole Connection with them.
826
922
  ~requireAttribute: option<string>=?,
827
- // The state's `@owner` field, when it declares one, plus the groups exempt from
828
- // scoping. Baked into the generated source because this resolver runs inside
829
- // AppSync with no Lambda in the path — there is nothing here that could read a
830
- // configuration value at request time, so the deploy is the only chance to
831
- // state it. Changing the elevated-group list therefore requires a redeploy,
832
- // which is worth knowing and is why it is a deployment-level setting rather
833
- // than a per-request one.
923
+ // The state's `@owner` field and the groups exempt from scoping. Baked in
924
+ // because no Lambda sits in this path to read a value at request time, so the
925
+ // elevated-group list changes only on redeploy.
834
926
  ~ownerField: option<string>=?,
835
927
  ~elevatedGroups: array<string>=[],
836
928
  // The state's `@retired` field, when it declares one. Baked in for the same
@@ -840,9 +932,17 @@ let listAllItemsConnection = (
840
932
  // The states that retire the row, for the state form of the annotation.
841
933
  // Absent is the boolean form, where the value is always `true`.
842
934
  ~retiredValues: option<array<string>>=?,
935
+ // The index `@owner` derives, and its sort key. Present turns the owner
936
+ // predicate from a post-read sieve into a key condition for a scoped caller.
937
+ ~ownerIndex: option<string>=?,
938
+ ~ownerIndexSortField: option<string>=?,
843
939
  ) => {
940
+ // The Query branch needs `ownerField` to key on; an index without one would
941
+ // have nothing to name in the key condition.
942
+ let ownerIndex = ownerField->Option.isSome ? ownerIndex : None
844
943
  let requireAttributeClause = switch requireAttribute {
845
- | Some(attr) => `
944
+ | Some(attr) =>
945
+ `
846
946
  names['#${attr}'] = '${attr}';
847
947
  parts.push('attribute_exists(#${attr})');`
848
948
  | None => ""
@@ -851,9 +951,11 @@ let listAllItemsConnection = (
851
951
  // it. The branch ORDER is the part that has to match: provider first, because
852
952
  // an IAM-signed service caller has no `sub` for a reason that has nothing to do
853
953
  // with being anonymous, and must not be refused as though it did.
854
- let ownerClause = switch ownerField {
855
- | None => ""
856
- | Some(field) =>
954
+ //
955
+ // Emitted in BOTH halves of the resolver when an owner index is in play: the
956
+ // response has to know which read minted the cursors it hands out, and that is
957
+ // the same question.
958
+ let ownerIdentityPreamble = {
857
959
  let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
858
960
  `
859
961
  // ── owner scoping (generated) ──
@@ -866,7 +968,16 @@ let listAllItemsConnection = (
866
968
  const _elevated = [${elevatedLiteral}];
867
969
  // No identity at all, or an identity with no \`sub\`, is the IAM service caller
868
970
  // the API also accepts — inside the trust boundary, and exempt.
869
- const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);
971
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`
972
+ }
973
+ let ownerClause = switch (ownerField, ownerIndex) {
974
+ | (None, _) => ""
975
+ // With an index the predicate is the Query's key condition, so nothing is
976
+ // pushed into the filter — and nothing may be: an expressionName the filter
977
+ // never references is a ValidationException, not a harmless extra.
978
+ | (Some(_), Some(_)) => ownerIdentityPreamble
979
+ | (Some(field), None) =>
980
+ `${ownerIdentityPreamble}
870
981
  if (!_exempt) {
871
982
  names['#owner'] = '${field}';
872
983
  values[':owner'] = util.dynamodb.toDynamoDB(_sub);
@@ -874,32 +985,23 @@ let listAllItemsConnection = (
874
985
  }`
875
986
  }
876
987
  // ── retirement narrowing (generated) ──
877
- // Reuses `_exempt` when the owner clause already computed it, and computes its
878
- // own when it did not — the two clauses are independently optional and either
879
- // may be the only one present.
880
- //
881
- // `includeRetired` IS read from ctx.args, unlike the owner predicate, and the
882
- // difference is deliberate: this argument does not say which rows the caller
883
- // wants, it asks to lift a restriction, and it is honoured only inside the
884
- // `_exempt` branch. A non-exempt caller passing it changes nothing.
988
+ // Reuses `_exempt` when the owner clause computed it; either clause may be the
989
+ // only one present. `includeRetired` IS read from ctx.args, unlike the owner
990
+ // predicate — it asks to lift a restriction rather than naming rows, and is
991
+ // honoured only inside `_exempt`.
885
992
  //
886
- // `attribute_not_exists OR = false` rather than `<> true`: a row written before
887
- // the annotation existed carries no such attribute, and DynamoDB's `<>` does
888
- // not match a missing one — the whole view would come back empty on the day
889
- // the annotation lands.
890
- //
891
- // The state form compares `<>` against the retiring state instead, under the
892
- // same `attribute_not_exists` guard and for the same reason. An equality
893
- // predicate over an enum-valued attribute indexes exactly as a boolean one
894
- // does, so the warning about an unindexed retirement field carries over
895
- // unchanged.
993
+ // `attribute_not_exists OR = false` rather than `<> true`, because `<>` does not
994
+ // match a missing attribute and the view would empty out the day the annotation
995
+ // lands. The state form compares `<>` against the retiring state under the same
996
+ // guard.
896
997
  let retiredClause = switch retiredField {
897
998
  | None => ""
898
999
  | Some(field) =>
899
1000
  let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
900
1001
  let exemptPrelude = switch ownerField {
901
1002
  | Some(_) => ""
902
- | None => `
1003
+ | None =>
1004
+ `
903
1005
  const _id = ctx.identity;
904
1006
  const _sub = _id == null ? null : _id.sub;
905
1007
  const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
@@ -920,10 +1022,13 @@ ${switch retiredValues {
920
1022
  // once for the whole clause — a row that states no lifecycle is not
921
1023
  // retired, the same reading every other adapter takes.
922
1024
  | Some(states) =>
923
- let placeholders = states->Array.mapWithIndex((state, i) => (`:retiredValue${Int.toString(i)}`, state))
1025
+ let placeholders =
1026
+ states->Array.mapWithIndex((state, i) => (`:retiredValue${Int.toString(i)}`, state))
924
1027
  let assignments =
925
1028
  placeholders
926
- ->Array.map(((ph, state)) => ` values['${ph}'] = util.dynamodb.toDynamoDB('${state}');`)
1029
+ ->Array.map(((ph, state)) =>
1030
+ ` values['${ph}'] = util.dynamodb.toDynamoDB('${state}');`
1031
+ )
927
1032
  ->Array.join("\n")
928
1033
  let comparisons =
929
1034
  placeholders->Array.map(((ph, _)) => `#retired <> ${ph}`)->Array.join(" AND ")
@@ -939,16 +1044,19 @@ ${switch retiredValues {
939
1044
  }
940
1045
  let filterClauses =
941
1046
  filterFields
942
- ->Array.map(f => `
1047
+ ->Array.map(f =>
1048
+ `
943
1049
  if (filter.${f}Eq !== undefined && filter.${f}Eq !== null && filter.${f}Eq !== '') {
944
1050
  names['#${f}'] = '${f}';
945
1051
  values[':${f}Eq'] = util.dynamodb.toDynamoDB(filter.${f}Eq);
946
1052
  parts.push('#${f} = :${f}Eq');
947
- }`)
1053
+ }`
1054
+ )
948
1055
  ->Array.join("")
949
1056
  let rangeClauses =
950
1057
  rangeFields
951
- ->Array.map(f => `
1058
+ ->Array.map(f =>
1059
+ `
952
1060
  if (filter.${f}From !== undefined && filter.${f}From !== null && filter.${f}From !== '') {
953
1061
  names['#${f}'] = '${f}';
954
1062
  values[':${f}From'] = util.dynamodb.toDynamoDB(filter.${f}From);
@@ -958,29 +1066,32 @@ ${switch retiredValues {
958
1066
  names['#${f}'] = '${f}';
959
1067
  values[':${f}To'] = util.dynamodb.toDynamoDB(filter.${f}To);
960
1068
  parts.push('#${f} <= :${f}To');
961
- }`)
1069
+ }`
1070
+ )
962
1071
  ->Array.join("")
963
- let sortFieldsLiteral =
964
- sortFields->Array.map(f => `'${f}'`)->Array.join(", ")
1072
+ let sortFieldsLiteral = sortFields->Array.map(f => `'${f}'`)->Array.join(", ")
1073
+ // When the Query branch already ordered on the index's own sort key, the page
1074
+ // arrives globally ordered and re-sorting it here is the one way to break that
1075
+ // order — a sort over a page is not a sort over the caller's rows.
1076
+ let sortGuard = switch (ownerIndex, ownerIndexSortField) {
1077
+ | (Some(_), Some(_)) => "!_indexOrdered && "
1078
+ | _ => ""
1079
+ }
965
1080
  let sortBlock = if sortFields->Array.length == 0 {
966
1081
  ""
967
1082
  } else {
968
- // APPSYNC_JS 1.0.0 forbids: Array.prototype.sort(comparator), arrow/function
969
- // expressions passed to sort, for/while loops, recursion, and ++/--. So we
970
- // can't run a comparator-driven sort and we can't write our own loop. Use a
971
- // schwartzian transform: encode each item as `<sortKey>\x01<json>`, run the
972
- // no-comparator default sort (lexicographic), reverse for DESC, and decode.
973
- // Numeric fields get zero-padded so lex order matches numeric order for
974
- // non-negative values (typical for IDs, counts, timestamps). Negatives sort
975
- // lexicographically — acceptable since DynamoDB sort keys are rarely signed
976
- // numbers. Nulls split out and append to the end regardless of direction.
1083
+ // APPSYNC_JS 1.0.0 forbids comparator sorts, loops, recursion and ++/--, so
1084
+ // this is a schwartzian transform: encode each item as `<sortKey>\x01<json>`,
1085
+ // default-sort lexicographically, reverse for DESC, decode. Numbers are
1086
+ // zero-padded so lex order matches numeric order for non-negative values;
1087
+ // nulls split out and append to the end either way.
977
1088
  `
978
1089
  // Per-page sort (Scan returns items in indeterminate order; ScanIndexForward
979
1090
  // does not apply to Scan). Global ordering across pages requires v1.5 index
980
1091
  // promotion; @scanSort is per-page even then.
981
1092
  const orderBy = ctx.args.orderBy;
982
1093
  const sortFields = [${sortFieldsLiteral}];
983
- if (orderBy && orderBy.field && sortFields.indexOf(orderBy.field) >= 0) {
1094
+ if (${sortGuard}orderBy && orderBy.field && sortFields.indexOf(orderBy.field) >= 0) {
984
1095
  const field = orderBy.field;
985
1096
  const nulls = items.filter(it => it[field] === null || it[field] === undefined);
986
1097
  const nonNulls = items.filter(it => it[field] !== null && it[field] !== undefined);
@@ -996,13 +1107,69 @@ ${switch retiredValues {
996
1107
  items = encoded.map(e => JSON.parse(e.split('\\x01')[1])).concat(nulls);
997
1108
  }`
998
1109
  }
1110
+ // Did the Query branch order this page itself? Answered the same way in both
1111
+ // halves, because both need it: the request to set `scanIndexForward`, the
1112
+ // response to leave an already-ordered page alone.
1113
+ let indexOrderedExpr = switch (ownerIndex, ownerIndexSortField) {
1114
+ | (Some(_), Some(sf)) => `!_exempt && !!(ctx.args.orderBy && ctx.args.orderBy.field === '${sf}')`
1115
+ | _ => "false"
1116
+ }
1117
+ // The two reads, chosen by the same test that used to choose a predicate.
1118
+ // Both target the one data source the resolver is attached to, so this is a
1119
+ // branch inside one resolver — no second field, no second data source, no
1120
+ // client change.
1121
+ let requestOperation = switch (ownerField, ownerIndex) {
1122
+ | (Some(field), Some(index)) =>
1123
+ `
1124
+ const _indexOrdered = ${indexOrderedExpr};
1125
+ const req = _exempt
1126
+ ? {
1127
+ operation: 'Scan',
1128
+ limit: ${listPageWindowBudget},
1129
+ nextToken: _window,
1130
+ }
1131
+ : {
1132
+ operation: 'Query',
1133
+ index: '${index}',
1134
+ query: {
1135
+ expression: '#owner = :owner',
1136
+ expressionNames: { '#owner': '${field}' },
1137
+ expressionValues: { ':owner': util.dynamodb.toDynamoDB(_sub) },
1138
+ },
1139
+ limit: ${listPageWindowBudget},
1140
+ nextToken: _window,
1141
+ scanIndexForward: !(_indexOrdered && ctx.args.orderBy.direction === 'DESC'),
1142
+ };`
1143
+ | _ =>
1144
+ `
1145
+ const req = {
1146
+ operation: 'Scan',
1147
+ limit: ${listPageWindowBudget},
1148
+ nextToken: _window,
1149
+ };`
1150
+ }
1151
+ // Only a door with two reads tests the tag, and only that door stamps one.
1152
+ let requestPathGuard = ownerIndex->Option.isSome ? cursorPathGuard : ""
1153
+ let responsePathPreamble = switch ownerIndex {
1154
+ | None => ""
1155
+ | Some(_) =>
1156
+ `${ownerIdentityPreamble}
1157
+ const _path = _exempt ? 's' : 'q';
1158
+ const _indexOrdered = ${indexOrderedExpr};`
1159
+ }
1160
+ let pageResponse = switch ownerIndex {
1161
+ | None => connectionPageResponse(~bidirectional=true)
1162
+ | Some(_) => connectionPageResponse(~pathExpr="_path", ~bidirectional=true)
1163
+ }
999
1164
  `${importUtil}
1000
1165
  export function request(ctx) {
1001
- // Scan cannot page backward (ScanIndexForward is Query-only). Fail loud rather than
1002
- // silently returning the forward page. The ordered {single}Items connection
1003
- // (queryItemsWithSortConditions) supports last/before — direct backward callers there.
1004
- if (ctx.args.before != null || ctx.args.last != null) {
1005
- util.error('Backward pagination (last/before) is not supported on full-list connections; use first/after.', 'UnsupportedPagination');
1166
+ // 'before' IS served — the page is cut backward out of the window the cursor
1167
+ // names. 'last' is not: "the last N rows of the list" needs the end of the
1168
+ // list, which a forward-only Scan cursor cannot reach. The ordered
1169
+ // {single}Items connection (queryItemsWithSortConditions) has a real keyset
1170
+ // cursor and honours both — direct callers who need 'last' there.
1171
+ if (ctx.args.last != null) {
1172
+ util.error('last is not supported on full-list connections; page backward with first and before.', 'UnsupportedPagination');
1006
1173
  }
1007
1174
  const filter = ctx.args.filter ?? {};
1008
1175
  const names = {};
@@ -1027,18 +1194,14 @@ export function request(ctx) {
1027
1194
  });
1028
1195
  parts.push('#id IN (' + placeholders.join(', ') + ')');
1029
1196
  }${filterClauses}${rangeClauses}${requireAttributeClause}${ownerClause}${retiredClause}
1030
- // The cursor is base64(JSON({ token, index })); decode the after arg back to the raw
1031
- // DynamoDB continuation token the response side emitted (Fix 1 round-trip).
1032
- let after = null;
1033
- if (ctx.args.after != null && ctx.args.after !== '') {
1034
- const parsed = JSON.parse(util.base64Decode(ctx.args.after));
1035
- after = parsed.token ?? null;
1036
- }
1037
- const req = {
1038
- operation: 'Scan',
1039
- limit: (ctx.args.first ?? 50),
1040
- nextToken: after,
1041
- };
1197
+ ${listCursorPreamble}${requestPathGuard}
1198
+ // The one page a cursor cannot reach: it begins in an earlier window, and a
1199
+ // continuation token cannot name the one before it. Refused by itself rather
1200
+ // than folded into the 'last' guard, because the two are different limits and a
1201
+ // caller can act on this one (page forward from the start).
1202
+ if (_backward && _upTo <= 0 && _window !== null) {
1203
+ util.error('The previous page begins in an earlier read window, which this cursor cannot name; page forward from the start.', 'UnsupportedPagination');
1204
+ }${requestOperation}
1042
1205
  if (parts.length > 0) {
1043
1206
  req.filter = {
1044
1207
  expression: parts.join(' AND '),
@@ -1050,30 +1213,7 @@ export function request(ctx) {
1050
1213
  }
1051
1214
  export function response(ctx) {
1052
1215
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
1053
- let items = ctx.result?.items ?? [];${sortBlock}
1054
- // One Scan continuation token per page; encode it (with the item's page index for a
1055
- // unique, opaque Relay cursor). The request side decodes .token back to the raw
1056
- // DynamoDB nextToken (Fix 1).
1057
- const next = ctx.result?.nextToken ?? null;
1058
- const edges = items.map((item, i) => ({
1059
- node: item,
1060
- cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
1061
- }));
1062
- // A filtered/1MB-capped page can be empty or short while next is still set (limit
1063
- // caps rows scanned, not returned). The token is page-level, so synthesise a
1064
- // boundary cursor from it alone so a client can resume past a fully-filtered-out
1065
- // page instead of restarting from page 1 (Fix 3). The request only reads .token,
1066
- // so index -1 is inert on resume.
1067
- const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
1068
- return {
1069
- edges,
1070
- pageInfo: {
1071
- hasNextPage: !!next,
1072
- hasPreviousPage: !!ctx.args.after,
1073
- startCursor: edges.length > 0 ? edges[0].cursor : boundary,
1074
- endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
1075
- },
1076
- };
1216
+ let items = ctx.result?.items ?? [];${responsePathPreamble}${sortBlock}${listCursorPreamble}${pageResponse}
1077
1217
  }
1078
1218
  `->Pulumi.Input.make
1079
1219
  }
@@ -1085,14 +1225,10 @@ export function response(ctx) {
1085
1225
  /**
1086
1226
  The response of a cross-table field (`@resolves`) over a Query-shaped read.
1087
1227
 
1088
- The narrowing is the TARGET's, not the declaring view's: the row comes out of the
1089
- target's table, so whether this caller may see it is the target's question — asked
1090
- here with the same `_owns` / `_live` guards every by-key door on that table asks.
1091
-
1092
- A nested field takes no `includeRetired` argument, so `_wantsRetired` is never
1093
- true and a retired row never travels through one. A reference that must keep
1094
- reading as a name after the archive took it is what `{list}Refs` + `@namedWhenRetired`
1095
- answers.
1228
+ The narrowing is the TARGET's, not the declaring view's — the row is the target's,
1229
+ so it answers with the same `_owns` / `_live` guards its by-key doors use. A
1230
+ nested field takes no `includeRetired`, so a retired row never travels through
1231
+ one; `{list}Refs` + `@namedWhenRetired` is that door.
1096
1232
  */
1097
1233
  let resolvedFieldResponse = (
1098
1234
  ~multi: bool,
@@ -1111,7 +1247,10 @@ let resolvedFieldResponse = (
1111
1247
  | None => "\n const _owns = (row) => true;"
1112
1248
  | Some(field) =>
1113
1249
  `
1114
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
1250
+ // ── owner scoping (generated) ──${ownerGuardPreamble(
1251
+ ~ownerField=field,
1252
+ ~elevatedGroups,
1253
+ )}`
1115
1254
  }
1116
1255
  let retiredPart = retiredGuardPreamble(
1117
1256
  ~retiredField,
@@ -1303,14 +1442,10 @@ ${response}
1303
1442
 
1304
1443
  /** `@resolvesMany` — the parent's id array batch-read from the target's table.
1305
1444
 
1306
- Returns a plain string: the table name is interpolated by the adapter via
1307
- `Pulumi.Output.apply`, because BatchGetItem's `tables` map keys on the literal
1308
- name.
1309
-
1310
- Same shape as `batchGetItemsByIds`, and narrowed by the same guards — the
1311
- target's, since the rows are the target's. Missing ids come back as nulls in
1312
- the result array (BatchGetItem preserves index correspondence) and are
1313
- dropped, so the field is shorter rather than null-holed. */
1445
+ A plain string, because BatchGetItem's `tables` map keys on the literal name,
1446
+ which the adapter interpolates via `Pulumi.Output.apply`. Same shape and
1447
+ guards as `batchGetItemsByIds`; missing ids come back null and are dropped, so
1448
+ the field is shorter rather than null-holed. */
1314
1449
  let resolveIds = (
1315
1450
  ~idsField: string,
1316
1451
  ~sortField: option<string>,
@@ -1318,13 +1453,14 @@ let resolveIds = (
1318
1453
  ~retiredField: option<string>=?,
1319
1454
  ~retiredValues: option<array<string>>=?,
1320
1455
  ~elevatedGroups: array<string>=[],
1321
- ) => (tableName: string) => {
1322
- let keysCode = switch sortField {
1323
- | Some(sf) =>
1324
- `id => ({ id: util.dynamodb.toString(id.id), ${sf}: util.dynamodb.toString(id.${sf}) })`
1325
- | None => `id => ({ id: util.dynamodb.toString(id) })`
1326
- }
1327
- `${importUtil}
1456
+ ) =>
1457
+ (tableName: string) => {
1458
+ let keysCode = switch sortField {
1459
+ | Some(sf) =>
1460
+ `id => ({ id: util.dynamodb.toString(id.id), ${sf}: util.dynamodb.toString(id.${sf}) })`
1461
+ | None => `id => ({ id: util.dynamodb.toString(id) })`
1462
+ }
1463
+ `${importUtil}
1328
1464
  import { runtime } from '@aws-appsync/utils';
1329
1465
  export function request(ctx) {
1330
1466
  const idList = ctx.source.${idsField} ?? [];
@@ -1341,36 +1477,34 @@ export function request(ctx) {
1341
1477
  }
1342
1478
  export function response(ctx) {
1343
1479
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);${switch ownerField {
1344
- | None => ""
1345
- | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1346
- }}${retiredGuardPreamble(
1347
- ~retiredField,
1348
- ~retiredValues,
1349
- ~elevatedGroups,
1350
- ~ownerScoped=ownerField->Option.isSome,
1351
- )}
1480
+ | None => ""
1481
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1482
+ }}${retiredGuardPreamble(
1483
+ ~retiredField,
1484
+ ~retiredValues,
1485
+ ~elevatedGroups,
1486
+ ~ownerScoped=ownerField->Option.isSome,
1487
+ )}
1352
1488
  return (ctx.result?.data?.['${tableName}'] ?? []).filter(item =>
1353
1489
  item !== null${ownerField->Option.isSome ? " && _owns(item)" : ""}${retiredField->Option.isSome
1354
- ? " && _live(item)"
1355
- : ""});
1490
+ ? " && _live(item)"
1491
+ : ""});
1356
1492
  }
1357
1493
  `
1358
- }
1494
+ }
1359
1495
 
1360
- /** Batched-by-ids — top-level Query resolver reading `ctx.args.ids: [String!]!`
1361
- and returning the matching items via a single BatchGetItem. Missing ids drop
1362
- out (BatchGetItem does not preserve cardinality); empty input short-circuits
1363
- to an empty result without hitting DDB. Table name is interpolated at deploy
1364
- time, since BatchGetItem's `tables` map keys on the literal table name.
1365
- Single-key tables only — composite-key BatchGetItem needs both pk + sk per
1366
- key entry, which this template doesn't construct. */
1496
+ /** Batched-by-ids — reads `ctx.args.ids: [String!]!` through one BatchGetItem.
1497
+ Missing ids drop out; empty input short-circuits without hitting DDB. The
1498
+ table name is interpolated at deploy time, since BatchGetItem's `tables` map
1499
+ keys on the literal. Single-key tables only. */
1367
1500
  let batchGetItemsByIds = (
1368
1501
  ~ownerField: option<string>=?,
1369
1502
  ~retiredField: option<string>=?,
1370
1503
  ~retiredValues: option<array<string>>=?,
1371
1504
  ~elevatedGroups: array<string>=[],
1372
- ) => (tableName: string) =>
1373
- `${importUtil}
1505
+ ) =>
1506
+ (tableName: string) =>
1507
+ `${importUtil}
1374
1508
  import { runtime } from '@aws-appsync/utils';
1375
1509
  export function request(ctx) {
1376
1510
  const ids = ctx.args.ids ?? [];
@@ -1387,79 +1521,79 @@ export function request(ctx) {
1387
1521
  }
1388
1522
  export function response(ctx) {
1389
1523
  if (ctx.error) util.error(ctx.error.message, ctx.error.type);
1390
- // BatchGetItem returns null in the result array for keys that don't exist
1391
- // in the table, preserving index correspondence with the input. The SDL
1392
- // returns this field as \`[T!]!\` (non-null element list), so any single
1393
- // missing id makes the entire field fail with "Cannot return null for
1394
- // non-nullable type" and the caller sees data=null. Filter the nulls so
1395
- // the field returns just the items that were found.
1524
+ // BatchGetItem returns null for keys that don't exist, preserving index
1525
+ // correspondence. The SDL declares \`[T!]!\`, so one missing id would null the
1526
+ // whole field — drop them and return what was found.
1396
1527
  // The owner and retirement guards the list pushes into a FilterExpression,
1397
1528
  // applied after the read because BatchGetItem has none to push into. A row the
1398
1529
  // caller does not own is dropped rather than refused, for the reason the
1399
1530
  // single-key door answers null: distinguishing "not yours" from "not there"
1400
1531
  // would make this door an oracle for which ids exist.${switch ownerField {
1401
- | None => ""
1402
- | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1403
- }}${retiredGuardPreamble(
1404
- ~retiredField,
1405
- ~retiredValues,
1406
- ~elevatedGroups,
1407
- ~ownerScoped=ownerField->Option.isSome,
1408
- )}
1532
+ | None => ""
1533
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1534
+ }}${retiredGuardPreamble(
1535
+ ~retiredField,
1536
+ ~retiredValues,
1537
+ ~elevatedGroups,
1538
+ ~ownerScoped=ownerField->Option.isSome,
1539
+ )}
1409
1540
  return (ctx.result?.data?.['${tableName}'] ?? []).filter(item =>
1410
1541
  item !== null${ownerField->Option.isSome ? " && _owns(item)" : ""}${retiredField->Option.isSome
1411
- ? " && _live(item)"
1412
- : ""});
1542
+ ? " && _live(item)"
1543
+ : ""});
1413
1544
  }
1414
1545
  `
1415
1546
 
1416
1547
  /** The reference door — `{list}Refs(ids)`: what a caller holding a pointer to a
1417
1548
  row may learn about it, and nothing else.
1418
1549
 
1419
- The same BatchGetItem as `batchGetItemsByIds`, projected in the response to
1420
- `{id, label, retired, retiredState}`. The projection is the type's — a caller
1421
- cannot ask for a price here because the SDL type has none — so the response
1422
- only has to *build* the three fields, never decide which to withhold.
1423
-
1424
- `namedWhenRetired` is what a retired row turns on: false drops it, exactly as
1425
- every other door does; true lets it through with the state that withdrew it.
1426
- The owner rule is applied either way and is not what the annotation lifts. */
1550
+ The same BatchGetItem as `batchGetItemsByIds`, projected to
1551
+ `{id, label, retired, retiredState}` by the SDL type, so the response builds
1552
+ three fields rather than deciding what to withhold. `namedWhenRetired` decides
1553
+ a retired row: false drops it, true names it. The owner rule applies either
1554
+ way — that is not what the annotation lifts. */
1427
1555
  let refsByIds = (
1428
1556
  ~labelField: string,
1429
1557
  ~retiredField: option<string>,
1430
1558
  ~retiredValues: option<array<string>>,
1431
1559
  ~namedWhenRetired: bool,
1560
+ // The picture a reference shows this row by, already read off the state schema
1561
+ // by `Reventless.RowImage` — this template only bakes in the expression it
1562
+ // hands over. `None` for a view that declares none, which emits a literal
1563
+ // `null`, exactly what the nullable field promises.
1564
+ ~imageExpr: option<string>=?,
1432
1565
  ~ownerField: option<string>=?,
1433
1566
  ~elevatedGroups: array<string>=[],
1434
- ) => (tableName: string) => {
1435
- let ownerGuard = switch ownerField {
1436
- // Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
1437
- // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
1438
- // arguments, but got 1") and AppSync rejects the whole resolver at create
1439
- // time with "The code contains one or more errors". Only a door that emits
1440
- // the call conditionally is safe with a bare `() => true`, and that is not a
1441
- // property worth relying on across three templates.
1442
- | None => "\n const _owns = (row) => true;"
1443
- | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1444
- }
1445
- // Retirement, in the vocabulary the row itself uses: a member test for the
1446
- // state form, truthiness for the boolean one. Absent keeps the row live, which
1447
- // is what a row written before the annotation is.
1448
- let retiredExpr = switch (retiredField, retiredValues) {
1449
- | (None, _) => "false"
1450
- | (Some(f), Some(values)) =>
1451
- let literal = values->Array.map(v => `'${v}'`)->Array.join(", ")
1452
- `[${literal}].indexOf(row['${f}']) >= 0`
1453
- | (Some(f), None) => `row['${f}'] === true`
1454
- }
1455
- // Only the state form has a state to name, and only a retired row reports one:
1456
- // this door names rows, it does not publish a lifecycle column to callers the
1457
- // list withholds.
1458
- let stateExpr = switch (retiredField, retiredValues) {
1459
- | (Some(f), Some(_)) => `_retired(row) ? (row['${f}'] ?? null) : null`
1460
- | _ => "null"
1461
- }
1462
- `${importUtil}
1567
+ ) =>
1568
+ (tableName: string) => {
1569
+ let ownerGuard = switch ownerField {
1570
+ // Takes the row it ignores: APPSYNC_JS type-checks the resolver, so a
1571
+ // zero-parameter stub called as `_owns(row)` is TS2554 ("Expected 0
1572
+ // arguments, but got 1") and AppSync rejects the whole resolver at create
1573
+ // time with "The code contains one or more errors". Only a door that emits
1574
+ // the call conditionally is safe with a bare `() => true`, and that is not a
1575
+ // property worth relying on across three templates.
1576
+ | None => "\n const _owns = (row) => true;"
1577
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1578
+ }
1579
+ // Retirement, in the vocabulary the row itself uses: a member test for the
1580
+ // state form, truthiness for the boolean one. Absent keeps the row live, which
1581
+ // is what a row written before the annotation is.
1582
+ let retiredExpr = switch (retiredField, retiredValues) {
1583
+ | (None, _) => "false"
1584
+ | (Some(f), Some(values)) =>
1585
+ let literal = values->Array.map(v => `'${v}'`)->Array.join(", ")
1586
+ `[${literal}].indexOf(row['${f}']) >= 0`
1587
+ | (Some(f), None) => `row['${f}'] === true`
1588
+ }
1589
+ // Only the state form has a state to name, and only a retired row reports one:
1590
+ // this door names rows, it does not publish a lifecycle column to callers the
1591
+ // list withholds.
1592
+ let stateExpr = switch (retiredField, retiredValues) {
1593
+ | (Some(f), Some(_)) => `_retired(row) ? (row['${f}'] ?? null) : null`
1594
+ | _ => "null"
1595
+ }
1596
+ `${importUtil}
1463
1597
  import { runtime } from '@aws-appsync/utils';
1464
1598
  export function request(ctx) {
1465
1599
  const ids = ctx.args.ids ?? [];
@@ -1484,19 +1618,19 @@ export function response(ctx) {
1484
1618
  .map(row => ({
1485
1619
  id: row.id,
1486
1620
  label: row['${labelField}'] ?? row.id,
1621
+ image: ${imageExpr->Option.getOr("null")},
1487
1622
  retired: _retired(row),
1488
1623
  retiredState: ${stateExpr},
1489
1624
  }));
1490
1625
  }
1491
1626
  `
1492
- }
1627
+ }
1493
1628
 
1494
1629
  // ---------------------------------------------------------------------------
1495
1630
  // DynamoDB write
1496
1631
  // ---------------------------------------------------------------------------
1497
1632
 
1498
- let putItem =
1499
- `${importUtil}
1633
+ let putItem = `${importUtil}
1500
1634
  export function request(ctx) {
1501
1635
  return {
1502
1636
  operation: 'PutItem',
@@ -1523,8 +1657,7 @@ export function request(ctx) {
1523
1657
  ${resultResponseCode}
1524
1658
  `->Pulumi.Input.make
1525
1659
 
1526
- let deleteItem =
1527
- `${importUtil}
1660
+ let deleteItem = `${importUtil}
1528
1661
  export function request(ctx) {
1529
1662
  return {
1530
1663
  operation: 'DeleteItem',
@@ -1667,8 +1800,7 @@ export function response(ctx) {
1667
1800
  let authorizeIndexedAccessRequest = authorizeIndexedAccess
1668
1801
 
1669
1802
  /** @deprecated The response check is now part of authorizeIndexedAccess. */
1670
- let authorizeIndexedAccessResponse = (~group as _: string) =>
1671
- resultResponseCode->Pulumi.Input.make
1803
+ let authorizeIndexedAccessResponse = (~group as _: string) => resultResponseCode->Pulumi.Input.make
1672
1804
 
1673
1805
  // ---------------------------------------------------------------------------
1674
1806
  // Response-only helpers