@reventlessdev/rescript-pulumi-aws 2.4.0-alpha.78 → 2.4.0-alpha.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 2.4.0-alpha.79 (2026-08-18)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **api:** make the by-index door answer, and let an elevated caller widen it ([0fe0c6f](https://github.com/ReventlessDev/reventless-core/commit/0fe0c6f8dec6228ecaba39577e28d780b4f79c83))
11
+ * **aws:** apply [@owner](https://github.com/owner) on the DynamoDB by-ids and by-index doors ([a6b5afc](https://github.com/ReventlessDev/reventless-core/commit/a6b5afc2923bfa4d3e0b2990367f67e8ffdd8877))
12
+ * **aws:** narrow retirement on every DynamoDB door, not only the list ([d6a799b](https://github.com/ReventlessDev/reventless-core/commit/d6a799b287b28e9c1f75e193adbf3f6328a6bf2d))
13
+ ### Features
14
+
15
+ * **core:** let a reference name a retired row, and let an elevated caller open one ([9e2623a](https://github.com/ReventlessDev/reventless-core/commit/9e2623a4b22487561607fcc0ca19d51726069ee4))
16
+
17
+
6
18
  # 2.4.0-alpha.78 (2026-08-16)
7
19
 
8
20
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/rescript-pulumi-aws",
3
- "version": "2.4.0-alpha.78",
3
+ "version": "2.4.0-alpha.79",
4
4
  "description": "ReScript bindings for @pulumi/aws",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -22,7 +22,7 @@
22
22
  "@pulumi/aws": "~7.19.0",
23
23
  "@pulumi/aws-native": "^1.62.0",
24
24
  "sury": "11.0.0-alpha.4",
25
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
25
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.12",
26
26
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19"
27
27
  },
28
28
  "devDependencies": {
@@ -55,6 +55,155 @@ let ownerGuardPreamble = (~ownerField: string, ~elevatedGroups: array<string>) =
55
55
  const _owns = (row) => row == null || _exempt || row['${ownerField}'] === _sub;`
56
56
  }
57
57
 
58
+ /**
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.
65
+ */
66
+ let exemptPreamble = (~elevatedGroups: array<string>) => {
67
+ let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
68
+ `
69
+ const _id = ctx.identity;
70
+ const _sub = _id == null ? null : _id.sub;
71
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
72
+ const _elevated = [${elevatedLiteral}];
73
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`
74
+ }
75
+
76
+ /**
77
+ A by-key read's retirement guard: `_live(row)`, true when the caller may see it.
78
+
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.
91
+
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.
94
+ */
95
+ let retiredGuardPreamble = (
96
+ ~retiredField: option<string>,
97
+ ~retiredValues: option<array<string>>,
98
+ ~elevatedGroups: array<string>,
99
+ ~ownerScoped: bool,
100
+ ) =>
101
+ switch retiredField {
102
+ | None => ""
103
+ | Some(field) =>
104
+ let prelude = ownerScoped ? "" : exemptPreamble(~elevatedGroups)
105
+ let isRetired = switch retiredValues {
106
+ | None => `row['${field}'] === true`
107
+ | Some(states) =>
108
+ let literal = states->Array.map(s => `'${s}'`)->Array.join(", ")
109
+ `[${literal}].indexOf(row['${field}']) >= 0`
110
+ }
111
+ `${prelude}
112
+ // ── retirement narrowing (generated) ──
113
+ const _wantsRetired = _exempt && ctx.args.includeRetired === true;
114
+ const _live = (row) =>
115
+ row == null || _wantsRetired || row['${field}'] == null || !(${isRetired});`
116
+ }
117
+
118
+ /**
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.
136
+ */
137
+ let ownerFilterClause = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
138
+ switch ownerField {
139
+ | None => ""
140
+ | Some(field) =>
141
+ let elevatedLiteral = elevatedGroups->Array.map(g => `'${g}'`)->Array.join(", ")
142
+ `
143
+ // ── owner scoping (generated) ──
144
+ // Not read from ctx.args: a predicate deciding what the caller may see must
145
+ // arrive on a channel the caller cannot name.
146
+ const _oid = ctx.identity;
147
+ const _osub = _oid == null ? null : _oid.sub;
148
+ const _ogroups = (_oid != null && _oid.claims != null && _oid.claims['cognito:groups']) || [];
149
+ const _oelevated = [${elevatedLiteral}];
150
+ if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0))) {
151
+ if (expression) expression += ' AND ';
152
+ names['#owner'] = '${field}';
153
+ values[':owner'] = _osub;
154
+ expression += '#owner = :owner';
155
+ }
156
+ `
157
+ }
158
+
159
+ /**
160
+ The same retirement predicate as `retiredGuardPreamble`, for a door that reads
161
+ with `Query` and therefore has a FilterExpression to put it in.
162
+
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.
171
+ */
172
+ let retiredFilterClause = (
173
+ ~retiredField: option<string>,
174
+ ~retiredValues: option<array<string>>,
175
+ ~elevatedGroups: array<string>,
176
+ ) =>
177
+ switch retiredField {
178
+ | None => ""
179
+ | Some(field) =>
180
+ let assignments = switch retiredValues {
181
+ | None => ` values[':retiredFalse'] = false;
182
+ expression += '(attribute_not_exists(#retired) OR #retired = :retiredFalse)';`
183
+ | Some(states) =>
184
+ let assigns =
185
+ states
186
+ ->Array.mapWithIndex((state, i) =>
187
+ ` values[':retiredValue${Int.toString(i)}'] = '${state}';`
188
+ )
189
+ ->Array.join("\n")
190
+ let comparisons =
191
+ states
192
+ ->Array.mapWithIndex((_, i) => `#retired <> :retiredValue${Int.toString(i)}`)
193
+ ->Array.join(" AND ")
194
+ `${assigns}
195
+ expression += '(attribute_not_exists(#retired) OR (${comparisons}))';`
196
+ }
197
+ `${exemptPreamble(~elevatedGroups)}
198
+ // ── retirement narrowing (generated) ──
199
+ if (!(_exempt && ctx.args.includeRetired === true)) {
200
+ if (expression) expression += ' AND ';
201
+ names['#retired'] = '${field}';
202
+ ${assignments}
203
+ }
204
+ `
205
+ }
206
+
58
207
  /**
59
208
  A by-key read's response, refusing a row the caller does not own.
60
209
 
@@ -66,29 +215,61 @@ the failure mode owner scoping exists to avoid. And an error would confirm the
66
215
  row exists to a caller who may not read it, which is a worse leak than the
67
216
  ambiguity it removes.
68
217
  */
69
- let ownerScopedResultResponse = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
70
- switch ownerField {
71
- | None => resultResponseCode
72
- | Some(field) =>
218
+ let ownerScopedResultResponse = (
219
+ ~ownerField: option<string>,
220
+ ~elevatedGroups: array<string>,
221
+ ~retiredField: option<string>=?,
222
+ ~retiredValues: option<array<string>>=?,
223
+ ) =>
224
+ switch (ownerField, retiredField) {
225
+ | (None, None) => resultResponseCode
226
+ | _ =>
227
+ let ownerPart = switch ownerField {
228
+ | None => "\n const _owns = () => true;"
229
+ | Some(field) =>
230
+ `
231
+ // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
232
+ }
233
+ let retiredPart = retiredGuardPreamble(
234
+ ~retiredField,
235
+ ~retiredValues,
236
+ ~elevatedGroups,
237
+ ~ownerScoped=ownerField->Option.isSome,
238
+ )
73
239
  `
74
240
  export function response(ctx) {
75
- if (ctx.error) util.error(ctx.error.message, ctx.error.type);
76
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}
77
- return _owns(ctx.result) ? ctx.result : null;
241
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
242
+ return _owns(ctx.result)${retiredField->Option.isSome ? " && _live(ctx.result)" : ""} ? ctx.result : null;
78
243
  }`
79
244
  }
80
245
 
81
246
  /** The `queryByIdSort` counterpart — same rule, over the first row of a Query. */
82
- let ownerScopedFirstResultResponse = (~ownerField: option<string>, ~elevatedGroups: array<string>) =>
83
- switch ownerField {
84
- | None => firstResultResponseCode
85
- | Some(field) =>
247
+ let ownerScopedFirstResultResponse = (
248
+ ~ownerField: option<string>,
249
+ ~elevatedGroups: array<string>,
250
+ ~retiredField: option<string>=?,
251
+ ~retiredValues: option<array<string>>=?,
252
+ ) =>
253
+ switch (ownerField, retiredField) {
254
+ | (None, None) => firstResultResponseCode
255
+ | _ =>
256
+ let ownerPart = switch ownerField {
257
+ | None => "\n const _owns = () => true;"
258
+ | Some(field) =>
259
+ `
260
+ // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}`
261
+ }
262
+ let retiredPart = retiredGuardPreamble(
263
+ ~retiredField,
264
+ ~retiredValues,
265
+ ~elevatedGroups,
266
+ ~ownerScoped=ownerField->Option.isSome,
267
+ )
86
268
  `
87
269
  export function response(ctx) {
88
- if (ctx.error) util.error(ctx.error.message, ctx.error.type);
89
- // ── owner scoping (generated) ──${ownerGuardPreamble(~ownerField=field, ~elevatedGroups)}
270
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerPart}${retiredPart}
90
271
  const _row = ctx.result.items[0] ?? null;
91
- return _owns(_row) ? _row : null;
272
+ return _owns(_row)${retiredField->Option.isSome ? " && _live(_row)" : ""} ? _row : null;
92
273
  }`
93
274
  }
94
275
 
@@ -157,7 +338,12 @@ export function response(ctx) {
157
338
  // declares no owner emits exactly the source it emitted before scoping existed.
158
339
  // A list that scopes beside a by-id read that does not is not a partial
159
340
  // delivery — it is a hole, reachable by anyone who can name a row.
160
- let getItemById = (~ownerField: option<string>=?, ~elevatedGroups: array<string>=[]) =>
341
+ let getItemById = (
342
+ ~ownerField: option<string>=?,
343
+ ~elevatedGroups: array<string>=[],
344
+ ~retiredField: option<string>=?,
345
+ ~retiredValues: option<array<string>>=?,
346
+ ) =>
161
347
  `${importUtil}
162
348
  export function request(ctx) {
163
349
  return {
@@ -165,7 +351,7 @@ export function request(ctx) {
165
351
  key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
166
352
  };
167
353
  }
168
- ${ownerScopedResultResponse(~ownerField, ~elevatedGroups)}
354
+ ${ownerScopedResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
169
355
  `->Pulumi.Input.make
170
356
 
171
357
  let queryById =
@@ -300,6 +486,8 @@ let queryByIdSort = (
300
486
  sortField: string,
301
487
  ~ownerField: option<string>=?,
302
488
  ~elevatedGroups: array<string>=[],
489
+ ~retiredField: option<string>=?,
490
+ ~retiredValues: option<array<string>>=?,
303
491
  ) =>
304
492
  `${importUtil}
305
493
  export function request(ctx) {
@@ -315,7 +503,7 @@ export function request(ctx) {
315
503
  }
316
504
  };
317
505
  }
318
- ${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups)}
506
+ ${ownerScopedFirstResultResponse(~ownerField, ~elevatedGroups, ~retiredField?, ~retiredValues?)}
319
507
  `->Pulumi.Input.make
320
508
 
321
509
  // ---------------------------------------------------------------------------
@@ -384,14 +572,84 @@ export function request(ctx) {
384
572
  ${resultResponseCode}
385
573
  `->Pulumi.Input.make
386
574
 
575
+ /**
576
+ The by-index door's response.
577
+
578
+ Cursors are the DynamoDB continuation token carrying the row's position in the
579
+ page, exactly as `listAllItemsConnection` builds them — the two doors page over
580
+ the same kind of result, so they page the same way. The boundary cursor covers
581
+ the case that door documents: a filtered page can come back empty while a token
582
+ is still set, and a client has to be able to resume past it.
583
+
584
+ This replaces returning `ctx.result` raw. The field has always been declared as
585
+ returning a `Connection!`, and handing back DynamoDB's `{items, nextToken}`
586
+ satisfied no part of that contract.
587
+ */
588
+ let indexConnectionResponseCode = `
589
+ export function response(ctx) {
590
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
591
+ const items = ctx.result?.items ?? [];
592
+ const next = ctx.result?.nextToken ?? null;
593
+ const edges = items.map((item, i) => ({
594
+ node: item,
595
+ cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
596
+ }));
597
+ const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
598
+ return {
599
+ edges,
600
+ pageInfo: {
601
+ hasNextPage: !!next,
602
+ hasPreviousPage: !!ctx.args.after,
603
+ startCursor: edges.length > 0 ? edges[0].cursor : boundary,
604
+ endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
605
+ },
606
+ };
607
+ }`
608
+
609
+ /** Refuses backward paging, for the reason `listAllItemsConnection` gives: the
610
+ cursor is DynamoDB's own continuation token, which only walks forward, so
611
+ `last`/`before` cannot be honoured and handing back the forward page would answer
612
+ a different question without saying so.
613
+
614
+ The arguments stay declared — one that came and went with the index's shape would
615
+ make every client feature-detect — and the local backend refuses them with the
616
+ same message, so the door reads the same either side of a deploy. */
617
+ let indexBackwardPagingGuard = `
618
+ if (args.before != null || args.last != null) {
619
+ util.error('Backward pagination (last/before) is not supported on by-index connections; use first/after.', 'UnsupportedPagination');
620
+ }`
621
+
622
+ /** Decodes the Relay `after` cursor back to the DynamoDB continuation token the
623
+ response side encoded. Mirrors `listAllItemsConnection`'s request half. */
624
+ let indexCursorPreamble = `
625
+ let after = null;
626
+ if (args.after != null && args.after !== '') {
627
+ const parsed = JSON.parse(util.base64Decode(args.after));
628
+ after = parsed.token ?? null;
629
+ }`
630
+
631
+ // The arguments the by-index door declares, none of which is a column to match
632
+ // on. `includeRetired` is a request to lift a restriction and the rest are
633
+ // paging; left unlisted, the filter loop below turns each into a
634
+ // `contains(#arg, :arg)` against an attribute no row carries, and the door
635
+ // answers nothing.
636
+ let indexReservedArgs = `key === 'first' || key === 'after' || key === 'last' || key === 'before' || key === 'includeRetired' || key === 'limit' || key === 'nextToken' || key === 'forward'`
637
+
387
638
  // AppSync JS runtime restrictions (APPSYNC_JS 1.0.0):
388
639
  // - No `for` loops (for/for-of/for-in all fail validation)
389
640
  // - No String() / .toString() — use '' + value instead
390
641
  // - Object.keys().forEach() works for iteration
391
- let queryByIndexFiltered = (~index: string, ~idField: string) =>
642
+ let queryByIndexFiltered = (
643
+ ~index: string,
644
+ ~idField: string,
645
+ ~ownerField: option<string>=?,
646
+ ~retiredField: option<string>=?,
647
+ ~retiredValues: option<array<string>>=?,
648
+ ~elevatedGroups: array<string>=[],
649
+ ) =>
392
650
  `${importUtil}
393
651
  export function request(ctx) {
394
- const args = ctx.args;
652
+ const args = ctx.args;${indexBackwardPagingGuard}
395
653
  const query = {
396
654
  expression: '#${idField} = :${idField}',
397
655
  expressionNames: { '#${idField}': '${idField}' },
@@ -402,7 +660,7 @@ export function request(ctx) {
402
660
  const values = {};
403
661
  Object.keys(args).forEach(key => {
404
662
  const value = args[key];
405
- if (key === '${idField}' || key === 'limit' || key === 'nextToken' || key === 'forward') return;
663
+ if (key === '${idField}' || ${indexReservedArgs}) return;
406
664
  if (value == null || value === '') return;
407
665
  if (expression) expression += ' AND';
408
666
  if (key === 'hideDeleted') {
@@ -417,12 +675,13 @@ export function request(ctx) {
417
675
  values[':' + key] = '' + value;
418
676
  }
419
677
  });
678
+ ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
420
679
  const result = {
421
680
  operation: 'Query',
422
681
  query,
423
682
  index: '${index}',
424
- limit: (args.limit ?? 50),
425
- nextToken: (args.nextToken ?? null),
683
+ limit: (args.first ?? 50),
684
+ nextToken: after,
426
685
  scanIndexForward: (args.forward ?? true)
427
686
  };
428
687
  if (expression) {
@@ -430,13 +689,21 @@ export function request(ctx) {
430
689
  }
431
690
  return result;
432
691
  }
433
- ${resultResponseCode}
692
+ ${indexConnectionResponseCode}
434
693
  `->Pulumi.Input.make
435
694
 
436
- let queryByIndexSortFiltered = (~index: string, ~idField: string, ~sortField: string) =>
695
+ let queryByIndexSortFiltered = (
696
+ ~index: string,
697
+ ~idField: string,
698
+ ~ownerField: option<string>=?,
699
+ ~sortField: string,
700
+ ~retiredField: option<string>=?,
701
+ ~retiredValues: option<array<string>>=?,
702
+ ~elevatedGroups: array<string>=[],
703
+ ) =>
437
704
  `${importUtil}
438
705
  export function request(ctx) {
439
- const args = ctx.args;
706
+ const args = ctx.args;${indexBackwardPagingGuard}
440
707
  const query = args.${sortField}
441
708
  ? {
442
709
  expression: '#${idField} = :${idField} AND #${sortField} = :${sortField}',
@@ -456,7 +723,7 @@ export function request(ctx) {
456
723
  const values = {};
457
724
  Object.keys(args).forEach(key => {
458
725
  const value = args[key];
459
- if (key === '${idField}' || key === '${sortField}' || key === 'limit' || key === 'nextToken' || key === 'forward') return;
726
+ if (key === '${idField}' || key === '${sortField}' || ${indexReservedArgs}) return;
460
727
  if (value == null || value === '') return;
461
728
  if (expression) expression += ' AND';
462
729
  if (key === 'hideDeleted') {
@@ -471,12 +738,13 @@ export function request(ctx) {
471
738
  values[':' + key] = '' + value;
472
739
  }
473
740
  });
741
+ ${ownerFilterClause(~ownerField, ~elevatedGroups)}${retiredFilterClause(~retiredField, ~retiredValues, ~elevatedGroups)}${indexCursorPreamble}
474
742
  const result = {
475
743
  operation: 'Query',
476
744
  query,
477
745
  index: '${index}',
478
- limit: (args.limit ?? 50),
479
- nextToken: (args.nextToken ?? null),
746
+ limit: (args.first ?? 50),
747
+ nextToken: after,
480
748
  scanIndexForward: (args.forward ?? true)
481
749
  };
482
750
  if (expression) {
@@ -484,7 +752,7 @@ export function request(ctx) {
484
752
  }
485
753
  return result;
486
754
  }
487
- ${resultResponseCode}
755
+ ${indexConnectionResponseCode}
488
756
  `->Pulumi.Input.make
489
757
 
490
758
  // ---------------------------------------------------------------------------
@@ -998,7 +1266,12 @@ export function response(ctx) {
998
1266
  time, since BatchGetItem's `tables` map keys on the literal table name.
999
1267
  Single-key tables only — composite-key BatchGetItem needs both pk + sk per
1000
1268
  key entry, which this template doesn't construct. */
1001
- let batchGetItemsByIds = (tableName: string) =>
1269
+ let batchGetItemsByIds = (
1270
+ ~ownerField: option<string>=?,
1271
+ ~retiredField: option<string>=?,
1272
+ ~retiredValues: option<array<string>>=?,
1273
+ ~elevatedGroups: array<string>=[],
1274
+ ) => (tableName: string) =>
1002
1275
  `${importUtil}
1003
1276
  import { runtime } from '@aws-appsync/utils';
1004
1277
  export function request(ctx) {
@@ -1022,10 +1295,98 @@ export function response(ctx) {
1022
1295
  // missing id makes the entire field fail with "Cannot return null for
1023
1296
  // non-nullable type" and the caller sees data=null. Filter the nulls so
1024
1297
  // the field returns just the items that were found.
1025
- return (ctx.result?.data?.['${tableName}'] ?? []).filter(item => item !== null);
1298
+ // The owner and retirement guards the list pushes into a FilterExpression,
1299
+ // applied after the read because BatchGetItem has none to push into. A row the
1300
+ // caller does not own is dropped rather than refused, for the reason the
1301
+ // single-key door answers null: distinguishing "not yours" from "not there"
1302
+ // would make this door an oracle for which ids exist.${switch ownerField {
1303
+ | None => ""
1304
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1305
+ }}${retiredGuardPreamble(
1306
+ ~retiredField,
1307
+ ~retiredValues,
1308
+ ~elevatedGroups,
1309
+ ~ownerScoped=ownerField->Option.isSome,
1310
+ )}
1311
+ return (ctx.result?.data?.['${tableName}'] ?? []).filter(item =>
1312
+ item !== null${ownerField->Option.isSome ? " && _owns(item)" : ""}${retiredField->Option.isSome
1313
+ ? " && _live(item)"
1314
+ : ""});
1026
1315
  }
1027
1316
  `
1028
1317
 
1318
+ /** The reference door — `{list}Refs(ids)`: what a caller holding a pointer to a
1319
+ row may learn about it, and nothing else.
1320
+
1321
+ The same BatchGetItem as `batchGetItemsByIds`, projected in the response to
1322
+ `{id, label, retired, retiredState}`. The projection is the type's — a caller
1323
+ cannot ask for a price here because the SDL type has none — so the response
1324
+ only has to *build* the three fields, never decide which to withhold.
1325
+
1326
+ `namedWhenRetired` is what a retired row turns on: false drops it, exactly as
1327
+ every other door does; true lets it through with the state that withdrew it.
1328
+ The owner rule is applied either way and is not what the annotation lifts. */
1329
+ let refsByIds = (
1330
+ ~labelField: string,
1331
+ ~retiredField: option<string>,
1332
+ ~retiredValues: option<array<string>>,
1333
+ ~namedWhenRetired: bool,
1334
+ ~ownerField: option<string>=?,
1335
+ ~elevatedGroups: array<string>=[],
1336
+ ) => (tableName: string) => {
1337
+ let ownerGuard = switch ownerField {
1338
+ | None => "\n const _owns = () => true;"
1339
+ | Some(field) => ownerGuardPreamble(~ownerField=field, ~elevatedGroups)
1340
+ }
1341
+ // Retirement, in the vocabulary the row itself uses: a member test for the
1342
+ // state form, truthiness for the boolean one. Absent keeps the row live, which
1343
+ // is what a row written before the annotation is.
1344
+ let retiredExpr = switch (retiredField, retiredValues) {
1345
+ | (None, _) => "false"
1346
+ | (Some(f), Some(values)) =>
1347
+ let literal = values->Array.map(v => `'${v}'`)->Array.join(", ")
1348
+ `[${literal}].indexOf(row['${f}']) >= 0`
1349
+ | (Some(f), None) => `row['${f}'] === true`
1350
+ }
1351
+ // Only the state form has a state to name, and only a retired row reports one:
1352
+ // this door names rows, it does not publish a lifecycle column to callers the
1353
+ // list withholds.
1354
+ let stateExpr = switch (retiredField, retiredValues) {
1355
+ | (Some(f), Some(_)) => `_retired(row) ? (row['${f}'] ?? null) : null`
1356
+ | _ => "null"
1357
+ }
1358
+ `${importUtil}
1359
+ import { runtime } from '@aws-appsync/utils';
1360
+ export function request(ctx) {
1361
+ const ids = ctx.args.ids ?? [];
1362
+ if (ids.length === 0) return runtime.earlyReturn([]);
1363
+ return {
1364
+ operation: 'BatchGetItem',
1365
+ tables: {
1366
+ '${tableName}': {
1367
+ keys: ids.map(id => ({ id: util.dynamodb.toDynamoDB(id) })),
1368
+ consistentRead: true,
1369
+ }
1370
+ }
1371
+ };
1372
+ }
1373
+ export function response(ctx) {
1374
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);${ownerGuard}
1375
+ const _retired = (row) => ${retiredExpr};
1376
+ const _namesRetired = ${namedWhenRetired ? "true" : "false"};
1377
+ return (ctx.result?.data?.['${tableName}'] ?? [])
1378
+ .filter(row => row !== null && _owns(row))
1379
+ .filter(row => _namesRetired || !_retired(row))
1380
+ .map(row => ({
1381
+ id: row.id,
1382
+ label: row['${labelField}'] ?? row.id,
1383
+ retired: _retired(row),
1384
+ retiredState: ${stateExpr},
1385
+ }));
1386
+ }
1387
+ `
1388
+ }
1389
+
1029
1390
  // ---------------------------------------------------------------------------
1030
1391
  // DynamoDB write
1031
1392
  // ---------------------------------------------------------------------------
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
4
 
4
5
  let resultResponseCode = `
5
6
  export function response(ctx) {
@@ -32,31 +33,112 @@ function ownerGuardPreamble(ownerField, elevatedGroups) {
32
33
  const _owns = (row) => row == null || _exempt || row['` + ownerField + `'] === _sub;`;
33
34
  }
34
35
 
35
- function ownerScopedResultResponse(ownerField, elevatedGroups) {
36
- if (ownerField !== undefined) {
37
- return `
38
- export function response(ctx) {
39
- if (ctx.error) util.error(ctx.error.message, ctx.error.type);
40
- // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) + `
41
- return _owns(ctx.result) ? ctx.result : null;
42
- }`;
36
+ function exemptPreamble(elevatedGroups) {
37
+ let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
38
+ return `
39
+ const _id = ctx.identity;
40
+ const _sub = _id == null ? null : _id.sub;
41
+ const _groups = (_id != null && _id.claims != null && _id.claims['cognito:groups']) || [];
42
+ const _elevated = [` + elevatedLiteral + `];
43
+ const _exempt = _sub == null || _groups.some(g => _elevated.indexOf(g) >= 0);`;
44
+ }
45
+
46
+ function retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, ownerScoped) {
47
+ if (retiredField === undefined) {
48
+ return "";
49
+ }
50
+ let prelude = ownerScoped ? "" : exemptPreamble(elevatedGroups);
51
+ let isRetired;
52
+ if (retiredValues !== undefined) {
53
+ let literal = retiredValues.map(s => `'` + s + `'`).join(", ");
54
+ isRetired = `[` + literal + `].indexOf(row['` + retiredField + `']) >= 0`;
43
55
  } else {
44
- return resultResponseCode;
56
+ isRetired = `row['` + retiredField + `'] === true`;
45
57
  }
58
+ return prelude + `
59
+ // ── retirement narrowing (generated) ──
60
+ const _wantsRetired = _exempt && ctx.args.includeRetired === true;
61
+ const _live = (row) =>
62
+ row == null || _wantsRetired || row['` + retiredField + `'] == null || !(` + isRetired + `);`;
46
63
  }
47
64
 
48
- function ownerScopedFirstResultResponse(ownerField, elevatedGroups) {
49
- if (ownerField !== undefined) {
50
- return `
65
+ function ownerFilterClause(ownerField, elevatedGroups) {
66
+ if (ownerField === undefined) {
67
+ return "";
68
+ }
69
+ let elevatedLiteral = elevatedGroups.map(g => `'` + g + `'`).join(", ");
70
+ return `
71
+ // ── owner scoping (generated) ──
72
+ // Not read from ctx.args: a predicate deciding what the caller may see must
73
+ // arrive on a channel the caller cannot name.
74
+ const _oid = ctx.identity;
75
+ const _osub = _oid == null ? null : _oid.sub;
76
+ const _ogroups = (_oid != null && _oid.claims != null && _oid.claims['cognito:groups']) || [];
77
+ const _oelevated = [` + elevatedLiteral + `];
78
+ if (!(_osub == null || _ogroups.some(g => _oelevated.indexOf(g) >= 0))) {
79
+ if (expression) expression += ' AND ';
80
+ names['#owner'] = '` + ownerField + `';
81
+ values[':owner'] = _osub;
82
+ expression += '#owner = :owner';
83
+ }
84
+ `;
85
+ }
86
+
87
+ function retiredFilterClause(retiredField, retiredValues, elevatedGroups) {
88
+ if (retiredField === undefined) {
89
+ return "";
90
+ }
91
+ let assignments;
92
+ if (retiredValues !== undefined) {
93
+ let assigns = retiredValues.map((state, i) => ` values[':retiredValue` + i.toString() + `'] = '` + state + `';`).join("\n");
94
+ let comparisons = retiredValues.map((param, i) => `#retired <> :retiredValue` + i.toString()).join(" AND ");
95
+ assignments = assigns + `
96
+ expression += '(attribute_not_exists(#retired) OR (` + comparisons + `))';`;
97
+ } else {
98
+ assignments = ` values[':retiredFalse'] = false;
99
+ expression += '(attribute_not_exists(#retired) OR #retired = :retiredFalse)';`;
100
+ }
101
+ return exemptPreamble(elevatedGroups) + `
102
+ // ── retirement narrowing (generated) ──
103
+ if (!(_exempt && ctx.args.includeRetired === true)) {
104
+ if (expression) expression += ' AND ';
105
+ names['#retired'] = '` + retiredField + `';
106
+ ` + assignments + `
107
+ }
108
+ `;
109
+ }
110
+
111
+ function ownerScopedResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) {
112
+ if (ownerField === undefined && retiredField === undefined) {
113
+ return resultResponseCode;
114
+ }
115
+ let ownerPart = ownerField !== undefined ? `
116
+ // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = () => true;";
117
+ let retiredPart = retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField));
118
+ return `
51
119
  export function response(ctx) {
52
- if (ctx.error) util.error(ctx.error.message, ctx.error.type);
53
- // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) + `
54
- const _row = ctx.result.items[0] ?? null;
55
- return _owns(_row) ? _row : null;
120
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerPart + retiredPart + `
121
+ return _owns(ctx.result)` + (
122
+ Stdlib_Option.isSome(retiredField) ? " && _live(ctx.result)" : ""
123
+ ) + ` ? ctx.result : null;
56
124
  }`;
57
- } else {
125
+ }
126
+
127
+ function ownerScopedFirstResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) {
128
+ if (ownerField === undefined && retiredField === undefined) {
58
129
  return firstResultResponseCode;
59
130
  }
131
+ let ownerPart = ownerField !== undefined ? `
132
+ // ── owner scoping (generated) ──` + ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = () => true;";
133
+ let retiredPart = retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField));
134
+ return `
135
+ export function response(ctx) {
136
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerPart + retiredPart + `
137
+ const _row = ctx.result.items[0] ?? null;
138
+ return _owns(_row)` + (
139
+ Stdlib_Option.isSome(retiredField) ? " && _live(_row)" : ""
140
+ ) + ` ? _row : null;
141
+ }`;
60
142
  }
61
143
 
62
144
  let pipelinePassThrough = importUtil + `
@@ -102,7 +184,7 @@ export function response(ctx) {
102
184
  `;
103
185
  }
104
186
 
105
- function getItemById(ownerField, elevatedGroupsOpt) {
187
+ function getItemById(ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
106
188
  let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
107
189
  return importUtil + `
108
190
  export function request(ctx) {
@@ -111,7 +193,7 @@ export function request(ctx) {
111
193
  key: { id: util.dynamodb.toDynamoDB(ctx.args.id) }
112
194
  };
113
195
  }
114
- ` + ownerScopedResultResponse(ownerField, elevatedGroups) + `
196
+ ` + ownerScopedResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) + `
115
197
  `;
116
198
  }
117
199
 
@@ -228,7 +310,7 @@ export function response(ctx) {
228
310
  `;
229
311
  }
230
312
 
231
- function queryByIdSort(sortField, ownerField, elevatedGroupsOpt) {
313
+ function queryByIdSort(sortField, ownerField, elevatedGroupsOpt, retiredField, retiredValues) {
232
314
  let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
233
315
  return importUtil + `
234
316
  export function request(ctx) {
@@ -244,7 +326,7 @@ export function request(ctx) {
244
326
  }
245
327
  };
246
328
  }
247
- ` + ownerScopedFirstResultResponse(ownerField, elevatedGroups) + `
329
+ ` + ownerScopedFirstResultResponse(ownerField, elevatedGroups, retiredField, retiredValues) + `
248
330
  `;
249
331
  }
250
332
 
@@ -313,10 +395,46 @@ export function request(ctx) {
313
395
  `;
314
396
  }
315
397
 
316
- function queryByIndexFiltered(index, idField) {
398
+ let indexConnectionResponseCode = `
399
+ export function response(ctx) {
400
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);
401
+ const items = ctx.result?.items ?? [];
402
+ const next = ctx.result?.nextToken ?? null;
403
+ const edges = items.map((item, i) => ({
404
+ node: item,
405
+ cursor: util.base64Encode(JSON.stringify({ token: next, index: i })),
406
+ }));
407
+ const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;
408
+ return {
409
+ edges,
410
+ pageInfo: {
411
+ hasNextPage: !!next,
412
+ hasPreviousPage: !!ctx.args.after,
413
+ startCursor: edges.length > 0 ? edges[0].cursor : boundary,
414
+ endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : boundary,
415
+ },
416
+ };
417
+ }`;
418
+
419
+ let indexBackwardPagingGuard = `
420
+ if (args.before != null || args.last != null) {
421
+ util.error('Backward pagination (last/before) is not supported on by-index connections; use first/after.', 'UnsupportedPagination');
422
+ }`;
423
+
424
+ let indexCursorPreamble = `
425
+ let after = null;
426
+ if (args.after != null && args.after !== '') {
427
+ const parsed = JSON.parse(util.base64Decode(args.after));
428
+ after = parsed.token ?? null;
429
+ }`;
430
+
431
+ let indexReservedArgs = `key === 'first' || key === 'after' || key === 'last' || key === 'before' || key === 'includeRetired' || key === 'limit' || key === 'nextToken' || key === 'forward'`;
432
+
433
+ function queryByIndexFiltered(index, idField, ownerField, retiredField, retiredValues, elevatedGroupsOpt) {
434
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
317
435
  return importUtil + `
318
436
  export function request(ctx) {
319
- const args = ctx.args;
437
+ const args = ctx.args;` + indexBackwardPagingGuard + `
320
438
  const query = {
321
439
  expression: '#` + idField + ` = :` + idField + `',
322
440
  expressionNames: { '#` + idField + `': '` + idField + `' },
@@ -327,7 +445,7 @@ export function request(ctx) {
327
445
  const values = {};
328
446
  Object.keys(args).forEach(key => {
329
447
  const value = args[key];
330
- if (key === '` + idField + `' || key === 'limit' || key === 'nextToken' || key === 'forward') return;
448
+ if (key === '` + idField + `' || ` + indexReservedArgs + `) return;
331
449
  if (value == null || value === '') return;
332
450
  if (expression) expression += ' AND';
333
451
  if (key === 'hideDeleted') {
@@ -342,12 +460,13 @@ export function request(ctx) {
342
460
  values[':' + key] = '' + value;
343
461
  }
344
462
  });
463
+ ` + ownerFilterClause(ownerField, elevatedGroups) + retiredFilterClause(retiredField, retiredValues, elevatedGroups) + indexCursorPreamble + `
345
464
  const result = {
346
465
  operation: 'Query',
347
466
  query,
348
467
  index: '` + index + `',
349
- limit: (args.limit ?? 50),
350
- nextToken: (args.nextToken ?? null),
468
+ limit: (args.first ?? 50),
469
+ nextToken: after,
351
470
  scanIndexForward: (args.forward ?? true)
352
471
  };
353
472
  if (expression) {
@@ -355,14 +474,15 @@ export function request(ctx) {
355
474
  }
356
475
  return result;
357
476
  }
358
- ` + resultResponseCode + `
477
+ ` + indexConnectionResponseCode + `
359
478
  `;
360
479
  }
361
480
 
362
- function queryByIndexSortFiltered(index, idField, sortField) {
481
+ function queryByIndexSortFiltered(index, idField, ownerField, sortField, retiredField, retiredValues, elevatedGroupsOpt) {
482
+ let elevatedGroups = elevatedGroupsOpt !== undefined ? elevatedGroupsOpt : [];
363
483
  return importUtil + `
364
484
  export function request(ctx) {
365
- const args = ctx.args;
485
+ const args = ctx.args;` + indexBackwardPagingGuard + `
366
486
  const query = args.` + sortField + `
367
487
  ? {
368
488
  expression: '#` + idField + ` = :` + idField + ` AND #` + sortField + ` = :` + sortField + `',
@@ -382,7 +502,7 @@ export function request(ctx) {
382
502
  const values = {};
383
503
  Object.keys(args).forEach(key => {
384
504
  const value = args[key];
385
- if (key === '` + idField + `' || key === '` + sortField + `' || key === 'limit' || key === 'nextToken' || key === 'forward') return;
505
+ if (key === '` + idField + `' || key === '` + sortField + `' || ` + indexReservedArgs + `) return;
386
506
  if (value == null || value === '') return;
387
507
  if (expression) expression += ' AND';
388
508
  if (key === 'hideDeleted') {
@@ -397,12 +517,13 @@ export function request(ctx) {
397
517
  values[':' + key] = '' + value;
398
518
  }
399
519
  });
520
+ ` + ownerFilterClause(ownerField, elevatedGroups) + retiredFilterClause(retiredField, retiredValues, elevatedGroups) + indexCursorPreamble + `
400
521
  const result = {
401
522
  operation: 'Query',
402
523
  query,
403
524
  index: '` + index + `',
404
- limit: (args.limit ?? 50),
405
- nextToken: (args.nextToken ?? null),
525
+ limit: (args.first ?? 50),
526
+ nextToken: after,
406
527
  scanIndexForward: (args.forward ?? true)
407
528
  };
408
529
  if (expression) {
@@ -410,7 +531,7 @@ export function request(ctx) {
410
531
  }
411
532
  return result;
412
533
  }
413
- ` + resultResponseCode + `
534
+ ` + indexConnectionResponseCode + `
414
535
  `;
415
536
  }
416
537
 
@@ -780,8 +901,10 @@ export function response(ctx) {
780
901
  `;
781
902
  }
782
903
 
783
- function batchGetItemsByIds(tableName) {
784
- return importUtil + `
904
+ function batchGetItemsByIds(ownerField, retiredField, retiredValues, $staropt$star) {
905
+ return tableName => {
906
+ let elevatedGroups = $staropt$star !== undefined ? $staropt$star : [];
907
+ return importUtil + `
785
908
  import { runtime } from '@aws-appsync/utils';
786
909
  export function request(ctx) {
787
910
  const ids = ctx.args.ids ?? [];
@@ -804,9 +927,73 @@ export function response(ctx) {
804
927
  // missing id makes the entire field fail with "Cannot return null for
805
928
  // non-nullable type" and the caller sees data=null. Filter the nulls so
806
929
  // the field returns just the items that were found.
807
- return (ctx.result?.data?.['` + tableName + `'] ?? []).filter(item => item !== null);
930
+ // The owner and retirement guards the list pushes into a FilterExpression,
931
+ // applied after the read because BatchGetItem has none to push into. A row the
932
+ // caller does not own is dropped rather than refused, for the reason the
933
+ // single-key door answers null: distinguishing "not yours" from "not there"
934
+ // would make this door an oracle for which ids exist.` + (
935
+ ownerField !== undefined ? ownerGuardPreamble(ownerField, elevatedGroups) : ""
936
+ ) + retiredGuardPreamble(retiredField, retiredValues, elevatedGroups, Stdlib_Option.isSome(ownerField)) + `
937
+ return (ctx.result?.data?.['` + tableName + `'] ?? []).filter(item =>
938
+ item !== null` + (
939
+ Stdlib_Option.isSome(ownerField) ? " && _owns(item)" : ""
940
+ ) + (
941
+ Stdlib_Option.isSome(retiredField) ? " && _live(item)" : ""
942
+ ) + `);
808
943
  }
809
944
  `;
945
+ };
946
+ }
947
+
948
+ function refsByIds(labelField, retiredField, retiredValues, namedWhenRetired, ownerField, $staropt$star) {
949
+ return tableName => {
950
+ let elevatedGroups = $staropt$star !== undefined ? $staropt$star : [];
951
+ let ownerGuard = ownerField !== undefined ? ownerGuardPreamble(ownerField, elevatedGroups) : "\n const _owns = () => true;";
952
+ let retiredExpr;
953
+ if (retiredField !== undefined) {
954
+ if (retiredValues !== undefined) {
955
+ let literal = retiredValues.map(v => `'` + v + `'`).join(", ");
956
+ retiredExpr = `[` + literal + `].indexOf(row['` + retiredField + `']) >= 0`;
957
+ } else {
958
+ retiredExpr = `row['` + retiredField + `'] === true`;
959
+ }
960
+ } else {
961
+ retiredExpr = "false";
962
+ }
963
+ let stateExpr = retiredField !== undefined && retiredValues !== undefined ? `_retired(row) ? (row['` + retiredField + `'] ?? null) : null` : "null";
964
+ return importUtil + `
965
+ import { runtime } from '@aws-appsync/utils';
966
+ export function request(ctx) {
967
+ const ids = ctx.args.ids ?? [];
968
+ if (ids.length === 0) return runtime.earlyReturn([]);
969
+ return {
970
+ operation: 'BatchGetItem',
971
+ tables: {
972
+ '` + tableName + `': {
973
+ keys: ids.map(id => ({ id: util.dynamodb.toDynamoDB(id) })),
974
+ consistentRead: true,
975
+ }
976
+ }
977
+ };
978
+ }
979
+ export function response(ctx) {
980
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type);` + ownerGuard + `
981
+ const _retired = (row) => ` + retiredExpr + `;
982
+ const _namesRetired = ` + (
983
+ namedWhenRetired ? "true" : "false"
984
+ ) + `;
985
+ return (ctx.result?.data?.['` + tableName + `'] ?? [])
986
+ .filter(row => row !== null && _owns(row))
987
+ .filter(row => _namesRetired || !_retired(row))
988
+ .map(row => ({
989
+ id: row.id,
990
+ label: row['` + labelField + `'] ?? row.id,
991
+ retired: _retired(row),
992
+ retiredState: ` + stateExpr + `,
993
+ }));
994
+ }
995
+ `;
996
+ };
810
997
  }
811
998
 
812
999
  let putItem = importUtil + `
@@ -1019,6 +1206,10 @@ export {
1019
1206
  resultListResponseCode,
1020
1207
  importUtil,
1021
1208
  ownerGuardPreamble,
1209
+ exemptPreamble,
1210
+ retiredGuardPreamble,
1211
+ ownerFilterClause,
1212
+ retiredFilterClause,
1022
1213
  ownerScopedResultResponse,
1023
1214
  ownerScopedFirstResultResponse,
1024
1215
  pipelinePassThrough,
@@ -1031,6 +1222,10 @@ export {
1031
1222
  queryByIndex,
1032
1223
  queryByIndexDeletable,
1033
1224
  queryByIndexSort,
1225
+ indexConnectionResponseCode,
1226
+ indexBackwardPagingGuard,
1227
+ indexCursorPreamble,
1228
+ indexReservedArgs,
1034
1229
  queryByIndexFiltered,
1035
1230
  queryByIndexSortFiltered,
1036
1231
  listAllItems,
@@ -1043,6 +1238,7 @@ export {
1043
1238
  resolveIdByIndexSortArgument,
1044
1239
  resolveIds,
1045
1240
  batchGetItemsByIds,
1241
+ refsByIds,
1046
1242
  putItem,
1047
1243
  addItemToList,
1048
1244
  deleteItem,
@@ -23,7 +23,7 @@ describe('Resolver code structure', () => {
23
23
  ['queryByIndexDeletable(index)', F.queryByIndexDeletable('userId')],
24
24
  ['queryByIndexSort(index,idField,sortField)', F.queryByIndexSort('userId', 'userId', 'createdAt')],
25
25
  ['queryByIndexFiltered(index,idField)', F.queryByIndexFiltered('userId', 'userId')],
26
- ['queryByIndexSortFiltered(index,idField,sortField)', F.queryByIndexSortFiltered('userId', 'userId', 'status')],
26
+ ['queryByIndexSortFiltered(index,idField,sortField)', F.queryByIndexSortFiltered('userId', 'userId', undefined, 'status')],
27
27
  ['listAllItems', F.listAllItems],
28
28
  ['resolveId(sourceIdField)', F.resolveId('productId')],
29
29
  ['resolveIdSort(sourceIdField,sourceSortField,targetSortField)', F.resolveIdSort('productId', 'status', 'status')],
@@ -437,7 +437,12 @@ describe('listAllItemsConnection', () => {
437
437
  // queryByIndexSortFiltered — complex template, most important to test
438
438
  // ---------------------------------------------------------------------------
439
439
  describe('queryByIndexSortFiltered', () => {
440
- const code = F.queryByIndexSortFiltered('ownerId', 'ownerId', 'status') // index, idField, sortField
440
+ // index, idField, ownerField, sortField `ownerField` is optional but sits
441
+ // ahead of `sortField`, so it has to be passed explicitly. Skipping it slid
442
+ // the sort key into the owner slot and this file scoped the read by `status`
443
+ // while asserting it did not, which is the failure mode a positional call
444
+ // against a labelled-argument function always has.
445
+ const code = F.queryByIndexSortFiltered('ownerId', 'ownerId', undefined, 'status')
441
446
  const { request, response } = evalResolver(code)
442
447
 
443
448
  test('query by idField only when sortField is absent', () => {
@@ -483,9 +488,19 @@ describe('queryByIndexSortFiltered', () => {
483
488
  expect(request(ctx).scanIndexForward).toBe(true)
484
489
  })
485
490
 
486
- test('response returns result on success', () => {
487
- const ctx = makeCtx({ result: { items: [{ id: 'x' }] } })
488
- expect(response(ctx)).toEqual({ items: [{ id: 'x' }] })
491
+ // The field this template is attached to has always been declared as returning
492
+ // a Connection; handing back DynamoDB's `{items, nextToken}` satisfied no part
493
+ // of that, so the door errored for every caller that selected `edges`.
494
+ test('response returns a Relay connection', () => {
495
+ const ctx = makeCtx({ result: { items: [{ id: 'x' }], nextToken: null } })
496
+ const out = response(ctx)
497
+ expect(out.edges.map(e => e.node)).toEqual([{ id: 'x' }])
498
+ expect(out.pageInfo.hasNextPage).toBe(false)
499
+ })
500
+
501
+ test('reports a further page when DynamoDB hands back a token', () => {
502
+ const ctx = makeCtx({ result: { items: [{ id: 'x' }], nextToken: 'TOK' } })
503
+ expect(response(ctx).pageInfo.hasNextPage).toBe(true)
489
504
  })
490
505
  })
491
506