@reventlessdev/reventless-aws 3.0.0-alpha.320 → 3.0.0-alpha.321

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,16 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.321 (2026-08-23)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** serve a full page from a filtered AppSync read ([b7dca5d](https://github.com/ReventlessDev/reventless-core/commit/b7dca5dac02e8c35c4848828152b90ade0f3c8ba))
11
+ ### Features
12
+
13
+ * **querydb:** serve owner-scoped lists from an index instead of a filtered Scan ([ad26c28](https://github.com/ReventlessDev/reventless-core/commit/ad26c284b4b0650fa3f3b6109ed33bbfe8608f6d))
14
+
15
+
6
16
  # 3.0.0-alpha.320 (2026-08-23)
7
17
 
8
18
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.320",
3
+ "version": "3.0.0-alpha.321",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -17,16 +17,16 @@
17
17
  "uuid": "^13.0.0",
18
18
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.13",
19
19
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
20
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
20
+ "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.4",
21
21
  "@reventlessdev/rescript-node": "2.0.0-alpha.8",
22
- "@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.3",
22
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
23
23
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
24
24
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
25
- "@reventlessdev/reventless-infra": "3.0.0-alpha.150",
26
- "@reventlessdev/reventless-core": "3.0.0-alpha.248",
25
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.151",
27
26
  "@reventlessdev/reventless-interop": "3.0.0-alpha.33",
28
- "@reventlessdev/reventless-spec": "3.0.0-alpha.122",
29
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.112"
27
+ "@reventlessdev/reventless-core": "3.0.0-alpha.249",
28
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.113",
29
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.123"
30
30
  },
31
31
  "devDependencies": {
32
32
  "rescript": "12.3.0",
@@ -283,13 +283,6 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
283
283
  // (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
284
284
  // See docs/plans/done/platform-plugins-admin-connection-null-rows.md.
285
285
  let requireAttribute = internalRowRequiredAttr(name)
286
- // A DynamoDB FilterExpression is applied AFTER the page is read, so a scoped
287
- // list over a table with no index on the owner field returns short pages —
288
- // correct, but pathological once a caller owns a small fraction of the rows.
289
- // Warned rather than refused: the resolver does serve the query, and a
290
- // deployment may legitimately accept the cost on a small table. Mirrors the
291
- // `@scanSort` alignment warning above, which exists for the same class of
292
- // "works, but scans" mistake.
293
286
  ReventlessCore.OwnerScopeDiagnostics.warnIfNoElevatedGroups(
294
287
  ~comp="QueryDbResolvers_AppSync",
295
288
  ~view=name,
@@ -298,17 +291,33 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
298
291
  let isIndexed = f =>
299
292
  indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
300
293
  subIdField->Option.getOr("") == f
301
- switch ownerField {
294
+ // The index `@owner` derives, and the sort key that orders one caller's rows
295
+ // inside it. Its absence means the author declined it — the list then falls
296
+ // back to the Scan-and-filter this used to prescribe an `@index` for.
297
+ let ownerIndexConfig = switch ownerField {
302
298
  | Some(f) =>
303
- if !isIndexed(f) {
304
- log.warn(
305
- ~comp="QueryDbResolvers_AppSync",
306
- `${name}: @owner field "${f}" is not the key of any index on this table. ` ++
307
- "Owner-scoped reads will Scan and filter, so pages shrink as the caller's " ++
308
- "share of the rows falls. Add an @index on that field before this read model grows.",
309
- )
310
- }
311
- | None => ()
299
+ indexes->Array.find(ic =>
300
+ Reventless.ReadModel.isDerivedIndex(ic) && ic.idField->Option.getOr(ic.index) == f
301
+ )
302
+ | None => None
303
+ }
304
+ let ownerIndex = ownerIndexConfig->Option.map(ic => ic.index)
305
+ let ownerIndexSortField = ownerIndexConfig->Option.flatMap(ic => ic.subIdField)
306
+ // Only reachable through `@owner({index: false})` now that the index is
307
+ // derived by default, so this states the cost of that choice rather than
308
+ // prescribing an `@index` — which would provision a second index on the same
309
+ // key and still not be the one the list reads.
310
+ switch (ownerField, ownerIndex) {
311
+ | (Some(f), None) if !isIndexed(f) =>
312
+ log.warn(
313
+ ~comp="QueryDbResolvers_AppSync",
314
+ `${name}: @owner field "${f}" keys no index on this table, so owner-scoped ` ++
315
+ "reads Scan the table and filter after the page is read — cost grows with the " ++
316
+ "table while the answer shrinks with the caller's share of it. Drop " ++
317
+ "`@owner({index: false})` to let the framework derive the index, or accept " ++
318
+ "the cost on a view that stays small.",
319
+ )
320
+ | _ => ()
312
321
  }
313
322
  // The same class of "works, but scans" mistake as the owner warning above,
314
323
  // and the retirement case degrades the same way: the FilterExpression is
@@ -346,12 +355,19 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
346
355
  ~elevatedGroups,
347
356
  ~retiredField?,
348
357
  ~retiredValues?,
358
+ ~ownerIndex?,
359
+ ~ownerIndexSortField?,
349
360
  )
350
361
  } else {
351
362
  Resolver.Functions.listAllItems
352
363
  },
353
364
  )
354
- let resolversByIndex = indexes->Array.map(({index} as indexConfig) => {
365
+ // Derived indexes are absent from the SDL (`GraphQL_FragmentGenerator` skips
366
+ // them), so a resolver here would attach to a field that does not exist and
367
+ // fail the deploy. The list resolver above is the only thing that reads one.
368
+ let resolversByIndex = indexes
369
+ ->Array.filter(ic => !Reventless.ReadModel.isDerivedIndex(ic))
370
+ ->Array.map(({index} as indexConfig) => {
355
371
  // Name and key field both come from `GraphQL_FragmentGenerator`, which is
356
372
  // where the SDL field this attaches to is derived. Deriving them here as
357
373
  // well is how the two came to disagree: the emitted field declared `id`
@@ -4,6 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
6
6
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
7
+ import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
7
8
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
9
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
9
10
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
@@ -123,14 +124,23 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
123
124
  return Stdlib_Option.getOr(subIdField, "") === f;
124
125
  }
125
126
  };
126
- if (ownerField !== undefined && !isIndexed(ownerField)) {
127
- log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" is not the key of any index on this table. ` + "Owner-scoped reads will Scan and filter, so pages shrink as the caller's share of the rows falls. Add an @index on that field before this read model grows.");
127
+ let ownerIndexConfig = ownerField !== undefined ? indexes.find(ic => {
128
+ if (ReadModel$Reventless.isDerivedIndex(ic)) {
129
+ return Stdlib_Option.getOr(ic.idField, ic.index) === ownerField;
130
+ } else {
131
+ return false;
132
+ }
133
+ }) : undefined;
134
+ let ownerIndex = Stdlib_Option.map(ownerIndexConfig, ic => ic.index);
135
+ let ownerIndexSortField = Stdlib_Option.flatMap(ownerIndexConfig, ic => ic.subIdField);
136
+ if (ownerField !== undefined && !(ownerIndex !== undefined || isIndexed(ownerField))) {
137
+ log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" keys no index on this table, so owner-scoped ` + "reads Scan the table and filter after the page is read — cost grows with the table while the answer shrinks with the caller's share of it. Drop `@owner({index: false})` to let the framework derive the index, or accept the cost on a view that stays small.");
128
138
  }
129
139
  if (retiredField !== undefined && !isIndexed(retiredField)) {
130
140
  log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @retired field "` + retiredField + `" is not the key of any index on this table. ` + "Reads that exclude retired rows will Scan and filter, so pages shrink as the archive's share of the rows grows. Add an @index on that field before this read model grows.");
131
141
  }
132
- let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, elevatedGroups, retiredField, retiredValues) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
133
- let resolversByIndex = indexes.map(indexConfig => {
142
+ let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, elevatedGroups, retiredField, retiredValues, ownerIndex, ownerIndexSortField) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
143
+ let resolversByIndex = indexes.filter(ic => !ReadModel$Reventless.isDerivedIndex(ic)).map(indexConfig => {
134
144
  let index = indexConfig.index;
135
145
  let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(fieldNameForSingle, index);
136
146
  let resolverName = Stdlib_String.capitalize(fieldName);
@@ -29,20 +29,35 @@ let globalSecondaryIndexes = (indexes: array<Reventless.ReadModel.indexConfig>)
29
29
  })
30
30
  ->Pulumi.Input.make
31
31
 
32
- let attributes = (sortField, indexes: array<Reventless.ReadModel.indexConfig>) =>
33
- [
34
- [{name: "id", type_: "S"}],
35
- sortField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
36
- indexes
37
- ->Array.map((indexConfig: Reventless.ReadModel.indexConfig) => {
38
- let {index, type_} = indexConfig
39
- [
40
- [{name: indexConfig.idField->Option.getOr(index), type_}],
41
- indexConfig.subIdField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
42
- ]->Array.flat
43
- })
44
- ->Array.flat,
45
- ]->Array.flat
32
+ // Pulumi rejects a table that defines the same attribute twice, and an index may
33
+ // legitimately key on one the table already declares — the derived `@owner` index
34
+ // sorts on `id`, and any index may sort on the table's own sort key. First
35
+ // declaration wins; they agree on the type because both name the same column.
36
+ let attributes = (sortField, indexes: array<Reventless.ReadModel.indexConfig>) => {
37
+ let all =
38
+ [
39
+ [{name: "id", type_: "S"}],
40
+ sortField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
41
+ indexes
42
+ ->Array.map((indexConfig: Reventless.ReadModel.indexConfig) => {
43
+ let {index, type_} = indexConfig
44
+ [
45
+ [{name: indexConfig.idField->Option.getOr(index), type_}],
46
+ indexConfig.subIdField->Option.mapOr([], sortField => [{name: sortField, type_: "S"}]),
47
+ ]->Array.flat
48
+ })
49
+ ->Array.flat,
50
+ ]->Array.flat
51
+ let seen = Set.make()
52
+ all->Array.filter(({name}) =>
53
+ if seen->Set.has(name) {
54
+ false
55
+ } else {
56
+ seen->Set.add(name)
57
+ true
58
+ }
59
+ )
60
+ }
46
61
 
47
62
  let dataSource = (name, table, api, apiRole, opts) => {
48
63
  let _dataSourceRolePolicy = {
@@ -38,7 +38,7 @@ function globalSecondaryIndexes(indexes) {
38
38
  }
39
39
 
40
40
  function attributes(sortField, indexes) {
41
- return [
41
+ let all = [
42
42
  [{
43
43
  name: "id",
44
44
  type: "S"
@@ -58,6 +58,16 @@ function attributes(sortField, indexes) {
58
58
  }])
59
59
  ].flat()).flat()
60
60
  ].flat();
61
+ let seen = new Set();
62
+ return all.filter(param => {
63
+ let name = param.name;
64
+ if (seen.has(name)) {
65
+ return false;
66
+ } else {
67
+ seen.add(name);
68
+ return true;
69
+ }
70
+ });
61
71
  }
62
72
 
63
73
  function dataSource(name, table, api, apiRole, opts) {
@@ -1,11 +1,7 @@
1
- // The DynamoDB backend narrows retirement on every door the rule names, not
2
- // only on the list.
3
- //
4
- // These assert the *generated resolver source*, which is the only artifact that
5
- // exists before a deploy: the predicate runs inside AppSync's JS runtime, so a
6
- // unit test cannot execute it against a table, and what can be checked here is
7
- // that each door carries the guard, in the half of the template its operation
8
- // allows, and that a view declaring no retirement is untouched.
1
+ // The DynamoDB backend narrows retirement on every door the rule names, not only
2
+ // on the list. These assert the generated resolver source — the only artifact
3
+ // that exists before a deploy — for the guard, the half of the template its
4
+ // operation allows, and a view declaring no retirement staying untouched.
9
5
 
10
6
  open JestGlobals
11
7
 
@@ -210,18 +206,20 @@ describe("the by-index door answers the field it is attached to", () => {
210
206
  testSync("returns a Relay connection, not the raw DynamoDB result", () =>
211
207
  expect((
212
208
  code->String.includes("edges,"),
213
- code->String.includes("hasNextPage: !!next"),
209
+ code->String.includes("hasNextPage: _more || !!_next"),
214
210
  code->String.includes("return ctx.result;"),
215
211
  ))->Expect.toEqual((true, true, false))
216
212
  )
217
213
 
218
214
  // `first`/`after` are what the field declares; `limit`/`nextToken` were what
219
- // the template read, and nothing translated between the two.
215
+ // the template read, and nothing translated between the two. `limit` counts
216
+ // rows examined, so a filtered read looks wider than the page it serves.
220
217
  testSync("pages on the Relay arguments the field declares", () =>
221
218
  expect((
222
- code->String.includes("limit: (args.first ?? 50)"),
219
+ code->String.includes("const _first = args.first ?? 50;"),
220
+ code->String.includes("expression ? (_first > 1000 ? _first : 1000)"),
223
221
  code->String.includes("util.base64Decode(args.after)"),
224
- ))->Expect.toEqual((true, true))
222
+ ))->Expect.toEqual((true, true, true))
225
223
  )
226
224
 
227
225
  // Every declared argument has a job other than matching a column, so each one
@@ -133,7 +133,7 @@ globalThis.describe("the by-index door answers the field it is attached to", ()
133
133
  globalThis.test("returns a Relay connection, not the raw DynamoDB result", () => {
134
134
  globalThis.expect([
135
135
  code.includes("edges,"),
136
- code.includes("hasNextPage: !!next"),
136
+ code.includes("hasNextPage: _more || !!_next"),
137
137
  code.includes("return ctx.result;")
138
138
  ]).toEqual([
139
139
  true,
@@ -143,9 +143,11 @@ globalThis.describe("the by-index door answers the field it is attached to", ()
143
143
  });
144
144
  globalThis.test("pages on the Relay arguments the field declares", () => {
145
145
  globalThis.expect([
146
- code.includes("limit: (args.first ?? 50)"),
146
+ code.includes("const _first = args.first ?? 50;"),
147
+ code.includes("expression ? (_first > 1000 ? _first : 1000)"),
147
148
  code.includes("util.base64Decode(args.after)")
148
149
  ]).toEqual([
150
+ true,
149
151
  true,
150
152
  true
151
153
  ]);
@@ -0,0 +1,53 @@
1
+ // The table half of the index `@owner` derives. Both assertions are about the
2
+ // DynamoDB resource the deploy would submit, which is the only artifact that
3
+ // exists before one.
4
+
5
+ open JestGlobals
6
+
7
+ // `Pulumi.Input.make` is `%identity`, so the provider-facing shape can be read
8
+ // back as the ReScript value it wraps.
9
+ external unwrap: Pulumi.Input.t<'a> => 'a = "%identity"
10
+
11
+ let ownerIndex: Reventless.ReadModel.indexConfig = {
12
+ index: "_owner",
13
+ type_: "S",
14
+ idField: "customerId",
15
+ subIdField: "id",
16
+ projectionType: ALL,
17
+ derived: true,
18
+ }
19
+
20
+ describe("the derived owner index", () => {
21
+ // Not KEYS_ONLY and not INCLUDE: the list door pushes the caller's filter, the
22
+ // retirement predicate and `requireAttribute` down as FilterExpressions over
23
+ // arbitrary columns, and a DynamoDB filter on a GSI may only name projected
24
+ // attributes. A row read through a narrow projection also comes back missing
25
+ // fields, which resolves a non-null SDL field to null.
26
+ testSync("is provisioned projecting every attribute", () => {
27
+ let gsis = QueryDbStorage_DynamoDb.globalSecondaryIndexes([ownerIndex])->unwrap
28
+ expect(
29
+ gsis->Array.map(g => {
30
+ let g = g->unwrap
31
+ (g.name, g.hashKey, g.rangeKey, g.projectionType)
32
+ }),
33
+ )->toEqual([("_owner", "customerId", Some("id"), PulumiAws.DynamoDb.Table.ALL)])
34
+ })
35
+
36
+ // Pulumi rejects a table that defines the same attribute twice, and the derived
37
+ // index sorts on `id` — the table's own partition key — on any view with no
38
+ // `@subId`. Undeduped, adding `@owner` to such a view fails the deploy outright.
39
+ testSync("does not redeclare an attribute the table already has", () => {
40
+ let names = QueryDbStorage_DynamoDb.attributes(None, [ownerIndex])->Array.map(a => a.name)
41
+ expect(names)->toEqual(["id", "customerId"])
42
+ })
43
+
44
+ // The same collision one step out: an index that sorts on the table's sort key.
45
+ testSync("dedupes against the table's own sort key too", () => {
46
+ let names =
47
+ QueryDbStorage_DynamoDb.attributes(
48
+ Some("placedAt"),
49
+ [{...ownerIndex, subIdField: "placedAt"}],
50
+ )->Array.map(a => a.name)
51
+ expect(names)->toEqual(["id", "placedAt", "customerId"])
52
+ })
53
+ })
@@ -0,0 +1,50 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as QueryDbStorage_DynamoDb$ReventlessAws from "../src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs";
4
+
5
+ let ownerIndex = {
6
+ index: "_owner",
7
+ type_: "S",
8
+ idField: "customerId",
9
+ subIdField: "id",
10
+ projectionType: "ALL",
11
+ derived: true
12
+ };
13
+
14
+ globalThis.describe("the derived owner index", () => {
15
+ globalThis.test("is provisioned projecting every attribute", () => {
16
+ let gsis = QueryDbStorage_DynamoDb$ReventlessAws.globalSecondaryIndexes([ownerIndex]);
17
+ globalThis.expect(gsis.map(g => [
18
+ g.name,
19
+ g.hashKey,
20
+ g.rangeKey,
21
+ g.projectionType
22
+ ])).toEqual([[
23
+ "_owner",
24
+ "customerId",
25
+ "id",
26
+ "ALL"
27
+ ]]);
28
+ });
29
+ globalThis.test("does not redeclare an attribute the table already has", () => {
30
+ let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes(undefined, [ownerIndex]).map(a => a.name);
31
+ globalThis.expect(names).toEqual([
32
+ "id",
33
+ "customerId"
34
+ ]);
35
+ });
36
+ globalThis.test("dedupes against the table's own sort key too", () => {
37
+ let newrecord = {...ownerIndex};
38
+ let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes("placedAt", [(newrecord.subIdField = "placedAt", newrecord)]).map(a => a.name);
39
+ globalThis.expect(names).toEqual([
40
+ "id",
41
+ "placedAt",
42
+ "customerId"
43
+ ]);
44
+ });
45
+ });
46
+
47
+ export {
48
+ ownerIndex,
49
+ }
50
+ /* Not a pure module */
@@ -1,10 +1,8 @@
1
1
  open JestGlobals
2
2
 
3
- // Regression guard for docs/plans/done/platform-plugins-admin-connection-null-rows.md:
4
- // the Plugins admin RM shares its DynamoDB table with internal bookkeeping rows
5
- // (`deploy-schema:*`, `plugin-info:*`, `deploy-schema-hash:*`) that carry no `name`.
6
- // The auto-generated Connection Scan must exclude them, or `name: String!` resolves
7
- // to null and nulls the entire Platform_PluginConnection ("No data" on the page).
3
+ // The Plugins admin RM shares its table with bookkeeping rows (`deploy-schema:*`,
4
+ // `plugin-info:*`, `deploy-schema-hash:*`) carrying no `name`. The Connection Scan
5
+ // must exclude them, or `name: String!` nulls the whole connection.
8
6
 
9
7
  // `Pulumi.Input.make` is `%identity`, so a generated resolver's `Pulumi.Input.t<string>`
10
8
  // IS the underlying JS string — recover it with Obj.magic for assertion.
@@ -39,43 +37,38 @@ describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
39
37
  })
40
38
  })
41
39
 
42
- // Tripwires for docs/plans/done/aws-scan-connection-cursor-roundtrip.md. The Scan
43
- // resolver's cursor must round-trip DynamoDB's own continuation token, not a
44
- // synthetic index every list past page 1 was unreachable before this.
45
- describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
40
+ // Tripwires for the Scan connection's paging. The behaviour itself is exercised
41
+ // against the evaluated resolver in `rescript/pulumi-aws`; these guard the wiring
42
+ // this package is responsible for emitting.
43
+ describe("listAllItemsConnection — paging", () => {
46
44
  let code = AppSync_Resolver_Retrying.Functions.listAllItemsConnection(~labelField="name")->codeOf
47
45
 
48
- testSync("Fix 1: response encodes the real nextToken as the cursor", () => {
49
- expect(code->String.includes("const next = ctx.result?.nextToken ?? null;"))->toBe(true)
50
- expect(
51
- code->String.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))"),
52
- )->toBe(true)
53
- expect(code->String.includes("hasNextPage: !!next,"))->toBe(true)
54
- })
55
-
56
- testSync("Fix 1: request decodes `after` back to the DynamoDB token", () => {
46
+ testSync("the cursor round-trips a read window, not a synthetic index", () => {
57
47
  expect(code->String.includes("JSON.parse(util.base64Decode(ctx.args.after))"))->toBe(true)
58
- expect(code->String.includes("nextToken: after,"))->toBe(true)
59
- })
60
-
61
- testSync("Fix 1: the old synthetic-index cursor is gone", () => {
48
+ expect(code->String.includes("nextToken: _window,"))->toBe(true)
62
49
  // The regression: `cursor: ctx.args.after ? ctx.args.after + '_' + i : '' + i`.
63
50
  expect(code->String.includes("ctx.args.after + '_' + i"))->toBe(false)
64
51
  })
65
52
 
66
- testSync("Fix 2: backward paging (last/before) is rejected, not silently mishandled", () => {
53
+ testSync("a filtered read examines more rows than it serves", () => {
54
+ expect(code->String.includes("parts.length > 0 ? (_first > 1000 ? _first : 1000)"))->toBe(true)
55
+ expect(code->String.includes("const _page = _rest.slice(0, _first);"))->toBe(true)
56
+ expect(code->String.includes("hasNextPage: _more || !!_next,"))->toBe(true)
57
+ })
58
+
59
+ testSync("backward paging (last/before) is rejected, not silently mishandled", () => {
67
60
  expect(code->String.includes("ctx.args.before != null || ctx.args.last != null"))->toBe(true)
68
61
  expect(code->String.includes("UnsupportedPagination"))->toBe(true)
69
62
  })
70
63
 
71
- testSync("Fix 3: an empty/short filtered page still yields a resumable boundary cursor", () => {
64
+ testSync("a window emptied by the filter still yields a resumable boundary cursor", () => {
72
65
  expect(
73
66
  code->String.includes(
74
- "const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;",
67
+ "const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1 })) : null;",
75
68
  ),
76
69
  )->toBe(true)
77
70
  expect(
78
- code->String.includes("edges.length > 0 ? edges[edges.length - 1].cursor : boundary"),
71
+ code->String.includes("edges.length > 0 ? edges[edges.length - 1].cursor : _boundary"),
79
72
  )->toBe(true)
80
73
  })
81
74
  })
@@ -19,37 +19,35 @@ globalThis.describe("QueryDbResolvers_AppSync.internalRowRequiredAttr", () => {
19
19
 
20
20
  globalThis.describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
21
21
  globalThis.test("with ~requireAttribute emits an attribute_exists filter that excludes internal rows", () => {
22
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined, undefined, undefined);
22
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined, undefined, undefined, undefined, undefined);
23
23
  globalThis.expect(code.includes("attribute_exists(#name)")).toBe(true);
24
24
  globalThis.expect(code.includes("names['#name'] = 'name'")).toBe(true);
25
25
  });
26
26
  globalThis.test("without ~requireAttribute emits no attribute_exists filter (default read models unchanged)", () => {
27
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
27
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
28
28
  globalThis.expect(code.includes("attribute_exists")).toBe(false);
29
29
  });
30
30
  });
31
31
 
32
- globalThis.describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
33
- let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
34
- globalThis.test("Fix 1: response encodes the real nextToken as the cursor", () => {
35
- globalThis.expect(code.includes("const next = ctx.result?.nextToken ?? null;")).toBe(true);
36
- globalThis.expect(code.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))")).toBe(true);
37
- globalThis.expect(code.includes("hasNextPage: !!next,")).toBe(true);
38
- });
39
- globalThis.test("Fix 1: request decodes `after` back to the DynamoDB token", () => {
32
+ globalThis.describe("listAllItemsConnection — paging", () => {
33
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
34
+ globalThis.test("the cursor round-trips a read window, not a synthetic index", () => {
40
35
  globalThis.expect(code.includes("JSON.parse(util.base64Decode(ctx.args.after))")).toBe(true);
41
- globalThis.expect(code.includes("nextToken: after,")).toBe(true);
42
- });
43
- globalThis.test("Fix 1: the old synthetic-index cursor is gone", () => {
36
+ globalThis.expect(code.includes("nextToken: _window,")).toBe(true);
44
37
  globalThis.expect(code.includes("ctx.args.after + '_' + i")).toBe(false);
45
38
  });
46
- globalThis.test("Fix 2: backward paging (last/before) is rejected, not silently mishandled", () => {
39
+ globalThis.test("a filtered read examines more rows than it serves", () => {
40
+ globalThis.expect(code.includes("parts.length > 0 ? (_first > 1000 ? _first : 1000)")).toBe(true);
41
+ globalThis.expect(code.includes("const _page = _rest.slice(0, _first);")).toBe(true);
42
+ globalThis.expect(code.includes("hasNextPage: _more || !!_next,")).toBe(true);
43
+ });
44
+ globalThis.test("backward paging (last/before) is rejected, not silently mishandled", () => {
47
45
  globalThis.expect(code.includes("ctx.args.before != null || ctx.args.last != null")).toBe(true);
48
46
  globalThis.expect(code.includes("UnsupportedPagination")).toBe(true);
49
47
  });
50
- globalThis.test("Fix 3: an empty/short filtered page still yields a resumable boundary cursor", () => {
51
- globalThis.expect(code.includes("const boundary = next ? util.base64Encode(JSON.stringify({ token: next, index: -1 })) : null;")).toBe(true);
52
- globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor : boundary")).toBe(true);
48
+ globalThis.test("a window emptied by the filter still yields a resumable boundary cursor", () => {
49
+ globalThis.expect(code.includes("const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1 })) : null;")).toBe(true);
50
+ globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor : _boundary")).toBe(true);
53
51
  });
54
52
  });
55
53