@reventlessdev/reventless-aws 3.0.0-alpha.321 → 3.0.0-alpha.323
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 +14 -0
- package/package.json +7 -7
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +40 -20
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +6 -2
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +27 -14
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +14 -6
- package/tests/QueryDbOwnerIndexTableTest.res +37 -0
- package/tests/QueryDbOwnerIndexTableTest.res.mjs +20 -0
- package/tests/QueryDbResolvers_AppSyncTest.res +20 -4
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
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.323 (2026-08-23)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **querydb:** serve the previous page instead of refusing it ([48341c9](https://github.com/ReventlessDev/reventless-core/commit/48341c9f61b5155b441deeb0edc9abf461948346))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.322 (2026-08-23)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **aws:** grant the AppSync data source Query on the table's indexes ([0dc5ed2](https://github.com/ReventlessDev/reventless-core/commit/0dc5ed2e783be22af5b418c94c95fc35f4b73851))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.321 (2026-08-23)
|
|
7
21
|
|
|
8
22
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.323",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
"sury": "11.0.0-rc.2",
|
|
17
17
|
"uuid": "^13.0.0",
|
|
18
18
|
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.13",
|
|
19
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
20
|
-
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.4",
|
|
21
19
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
20
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
21
|
+
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.5",
|
|
22
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.151",
|
|
26
25
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.33",
|
|
27
|
-
"@reventlessdev/reventless-
|
|
28
|
-
"@reventlessdev/reventless-
|
|
29
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.123"
|
|
26
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.114",
|
|
27
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.250",
|
|
28
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.123",
|
|
29
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.151"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"rescript": "12.3.0",
|
|
@@ -319,27 +319,47 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
319
319
|
)
|
|
320
320
|
| _ => ()
|
|
321
321
|
}
|
|
322
|
-
// The same
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
// the table grows.
|
|
322
|
+
// The same "works, but scans" cost as the owner warning above: the
|
|
323
|
+
// FilterExpression is applied after the page is read, so pages shrink as the
|
|
324
|
+
// archive's share of the table grows.
|
|
326
325
|
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// here
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
326
|
+
// What it must NOT say any more is "add an @index on that field". That was
|
|
327
|
+
// the same defect the owner warning had — following it changed nothing — and
|
|
328
|
+
// here it is worse advice, twice over: a GSI partitioned on a two- or
|
|
329
|
+
// three-valued flag funnels the whole table through one partition, and the
|
|
330
|
+
// list door reads neither it nor the by-index door it provisions.
|
|
331
|
+
//
|
|
332
|
+
// Retirement has no partition key of its own. It is only useful as the
|
|
333
|
+
// leading component of a sort key INSIDE some other partition, and `@owner`
|
|
334
|
+
// is what supplies one — see
|
|
335
|
+
// docs/plans/Backlog/retirement-folded-into-the-owner-index.md. On a view
|
|
336
|
+
// with no owner there is nothing to fold into, and the honest answer is a
|
|
337
|
+
// purpose-built view or the constant-PK index in
|
|
338
|
+
// docs/plans/Backlog/aws-fulllist-ordered-index-promotion.md.
|
|
339
|
+
//
|
|
340
|
+
// Fires whatever the field keys, unlike the owner check: an `@index` on the
|
|
341
|
+
// retirement field does not make this read cheaper, so suppressing on one
|
|
342
|
+
// would silence a cost that is still being paid. `@scan` does not satisfy it
|
|
343
|
+
// either — it widens the client's filter surface and removes no read unit.
|
|
344
|
+
switch (retiredField, ownerField) {
|
|
345
|
+
| (Some(f), Some(o)) =>
|
|
346
|
+
log.warn(
|
|
347
|
+
~comp="QueryDbResolvers_AppSync",
|
|
348
|
+
`${name}: @retired field "${f}" is filtered after the page is read, so pages ` ++
|
|
349
|
+
`shrink as the archive's share of the rows grows. This view is scoped on "${o}", ` ++
|
|
350
|
+
"so a scoped caller only sifts their own partition and pays in proportion to " ++
|
|
351
|
+
"their rows; it is the elevated whole-table read that pays in full.",
|
|
352
|
+
)
|
|
353
|
+
| (Some(f), None) =>
|
|
354
|
+
log.warn(
|
|
355
|
+
~comp="QueryDbResolvers_AppSync",
|
|
356
|
+
`${name}: @retired field "${f}" is filtered after the page is read, so pages ` ++
|
|
357
|
+
"shrink as the archive's share of the rows grows. An @index on that field " ++
|
|
358
|
+
"would not help — the list read does not use one, and a flag makes a poor " ++
|
|
359
|
+
"partition key. Reshape the view so the rows you serve are the rows you " ++
|
|
360
|
+
"keep, or accept the cost while the archive stays small.",
|
|
361
|
+
)
|
|
362
|
+
| (None, _) => ()
|
|
343
363
|
}
|
|
344
364
|
let resolverAll = makeQueryResolver(
|
|
345
365
|
~resolverName=fieldNameForAll->String.capitalize,
|
|
@@ -136,8 +136,12 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
|
|
|
136
136
|
if (ownerField !== undefined && !(ownerIndex !== undefined || isIndexed(ownerField))) {
|
|
137
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.");
|
|
138
138
|
}
|
|
139
|
-
if (retiredField !== undefined
|
|
140
|
-
|
|
139
|
+
if (retiredField !== undefined) {
|
|
140
|
+
if (ownerField !== undefined) {
|
|
141
|
+
log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @retired field "` + retiredField + `" is filtered after the page is read, so pages ` + (`shrink as the archive's share of the rows grows. This view is scoped on "` + ownerField + `", `) + "so a scoped caller only sifts their own partition and pays in proportion to their rows; it is the elevated whole-table read that pays in full.");
|
|
142
|
+
} else {
|
|
143
|
+
log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @retired field "` + retiredField + `" is filtered after the page is read, so pages ` + "shrink as the archive's share of the rows grows. An @index on that field would not help — the list read does not use one, and a flag makes a poor partition key. Reshape the view so the rows you serve are the rows you keep, or accept the cost while the archive stays small.");
|
|
144
|
+
}
|
|
141
145
|
}
|
|
142
146
|
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
147
|
let resolversByIndex = indexes.filter(ic => !ReadModel$Reventless.isDerivedIndex(ic)).map(indexConfig => {
|
|
@@ -59,26 +59,39 @@ let attributes = (sortField, indexes: array<Reventless.ReadModel.indexConfig>) =
|
|
|
59
59
|
)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
The DynamoDB grant the AppSync data source assumes.
|
|
64
|
+
|
|
65
|
+
An index is a SEPARATE IAM resource (`<table>/index/<name>`), so a grant naming
|
|
66
|
+
only the table permits `Scan` and `GetItem` and denies every `Query` against a
|
|
67
|
+
GSI — silently at deploy time, loudly at the first request. Two doors read one:
|
|
68
|
+
the by-index door, and the list door's owner-scoped branch.
|
|
69
|
+
|
|
70
|
+
Pure and named so the resource list is assertable without a deploy; the failure
|
|
71
|
+
it guards has no other artifact to check.
|
|
72
|
+
*/
|
|
73
|
+
let dataSourcePolicyDocument = (~name: string, ~tableArn: string): string => {
|
|
74
|
+
open PolicyDocument
|
|
75
|
+
PolicyDocument.make(
|
|
76
|
+
~id=name ++ "DataSourcePolicy",
|
|
77
|
+
~statements=[
|
|
78
|
+
{
|
|
79
|
+
sid: "AllowDynamoDbActions",
|
|
80
|
+
effect: Allow,
|
|
81
|
+
actions: Action("dynamodb:*"),
|
|
82
|
+
resources: Resources([tableArn, tableArn ++ "/index/*"]),
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
)->toJsonString
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
let dataSource = (name, table, api, apiRole, opts) => {
|
|
63
89
|
let _dataSourceRolePolicy = {
|
|
64
90
|
IAM.RolePolicy.make(
|
|
65
91
|
~name,
|
|
66
92
|
~args={
|
|
67
93
|
IAM.RolePolicy.policy: table.arn
|
|
68
|
-
->Pulumi.Output.apply(tableArn =>
|
|
69
|
-
open PolicyDocument
|
|
70
|
-
PolicyDocument.make(
|
|
71
|
-
~id=name ++ "DataSourcePolicy",
|
|
72
|
-
~statements=[
|
|
73
|
-
{
|
|
74
|
-
sid: "AllowDynamoDbActions",
|
|
75
|
-
effect: Allow,
|
|
76
|
-
actions: Action("dynamodb:*"),
|
|
77
|
-
resources: Resource(tableArn),
|
|
78
|
-
},
|
|
79
|
-
],
|
|
80
|
-
)->toJsonString
|
|
81
|
-
})
|
|
94
|
+
->Pulumi.Output.apply(tableArn => dataSourcePolicyDocument(~name, ~tableArn))
|
|
82
95
|
->Pulumi.Output.asInput,
|
|
83
96
|
role: apiRole
|
|
84
97
|
->Pulumi.Output.flatMap((role: PulumiAws.IAM.Role.t) => role.id)
|
|
@@ -70,14 +70,21 @@ function attributes(sortField, indexes) {
|
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function dataSourcePolicyDocument(name, tableArn) {
|
|
74
|
+
return PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, name + "DataSourcePolicy", [{
|
|
75
|
+
Sid: "AllowDynamoDbActions",
|
|
76
|
+
Effect: "Allow",
|
|
77
|
+
Action: "dynamodb:*",
|
|
78
|
+
Resource: [
|
|
79
|
+
tableArn,
|
|
80
|
+
tableArn + "/index/*"
|
|
81
|
+
]
|
|
82
|
+
}]));
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
function dataSource(name, table, api, apiRole, opts) {
|
|
74
86
|
new (Aws.iam.RolePolicy)(name, {
|
|
75
|
-
policy: table.arn.apply(tableArn =>
|
|
76
|
-
Sid: "AllowDynamoDbActions",
|
|
77
|
-
Effect: "Allow",
|
|
78
|
-
Action: "dynamodb:*",
|
|
79
|
-
Resource: tableArn
|
|
80
|
-
}]))),
|
|
87
|
+
policy: table.arn.apply(tableArn => dataSourcePolicyDocument(name, tableArn)),
|
|
81
88
|
role: Output$Pulumi.flatMap(apiRole, role => role.id)
|
|
82
89
|
}, opts);
|
|
83
90
|
return AppSync_DataSource$PulumiAws.makeDynamoDBDataSource(name, api, table, apiRole, opts);
|
|
@@ -104,6 +111,7 @@ function make(name, indexes, subIdField, ttl, api, apiRole, owner, opts) {
|
|
|
104
111
|
export {
|
|
105
112
|
globalSecondaryIndexes,
|
|
106
113
|
attributes,
|
|
114
|
+
dataSourcePolicyDocument,
|
|
107
115
|
dataSource,
|
|
108
116
|
make,
|
|
109
117
|
}
|
|
@@ -51,3 +51,40 @@ describe("the derived owner index", () => {
|
|
|
51
51
|
expect(names)->toEqual(["id", "placedAt", "customerId"])
|
|
52
52
|
})
|
|
53
53
|
})
|
|
54
|
+
|
|
55
|
+
// Provisioning the index is half of it. An index is a separate IAM resource, so
|
|
56
|
+
// a grant naming only the table lets every Scan through and denies every Query
|
|
57
|
+
// against a GSI — which is how the owner-scoped branch reached production
|
|
58
|
+
// deniable: nothing in the generated resolver, the table config or the SDL says
|
|
59
|
+
// anything about it, and the first request is where it surfaces.
|
|
60
|
+
describe("the data source's DynamoDB grant", () => {
|
|
61
|
+
let tableArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/Orders-10f6a39"
|
|
62
|
+
let policy =
|
|
63
|
+
QueryDbStorage_DynamoDb.dataSourcePolicyDocument(~name="Orders", ~tableArn)->JSON.parseOrThrow
|
|
64
|
+
|
|
65
|
+
let field = (json, key) => json->JSON.Decode.object->Option.flatMap(o => o->Dict.get(key))
|
|
66
|
+
let statement =
|
|
67
|
+
policy
|
|
68
|
+
->field("Statement")
|
|
69
|
+
->Option.flatMap(JSON.Decode.array)
|
|
70
|
+
->Option.getOr([])
|
|
71
|
+
->Array.get(0)
|
|
72
|
+
->Option.getOr(JSON.Encode.null)
|
|
73
|
+
let resources =
|
|
74
|
+
statement
|
|
75
|
+
->field("Resource")
|
|
76
|
+
->Option.flatMap(JSON.Decode.array)
|
|
77
|
+
->Option.getOr([])
|
|
78
|
+
->Array.filterMap(JSON.Decode.string)
|
|
79
|
+
|
|
80
|
+
testSync("covers the table's indexes, not only the table", () =>
|
|
81
|
+
expect(resources)->toEqual([tableArn, tableArn ++ "/index/*"])
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
// Dropping the table itself would break Scan — the elevated branch — while the
|
|
85
|
+
// scoped branch kept working, so both halves are asserted rather than just the
|
|
86
|
+
// one that was missing.
|
|
87
|
+
testSync("still covers the table itself", () =>
|
|
88
|
+
expect(resources->Array.includes(tableArn))->toBe(true)
|
|
89
|
+
)
|
|
90
|
+
})
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
4
|
+
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
3
6
|
import * as QueryDbStorage_DynamoDb$ReventlessAws from "../src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs";
|
|
4
7
|
|
|
5
8
|
let ownerIndex = {
|
|
@@ -44,6 +47,23 @@ globalThis.describe("the derived owner index", () => {
|
|
|
44
47
|
});
|
|
45
48
|
});
|
|
46
49
|
|
|
50
|
+
globalThis.describe("the data source's DynamoDB grant", () => {
|
|
51
|
+
let tableArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/Orders-10f6a39";
|
|
52
|
+
let policy = JSON.parse(QueryDbStorage_DynamoDb$ReventlessAws.dataSourcePolicyDocument("Orders", tableArn));
|
|
53
|
+
let field = (json, key) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o[key]);
|
|
54
|
+
let statement = Stdlib_Option.getOr(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(policy, "Statement"), Stdlib_JSON.Decode.array), [])[0], null);
|
|
55
|
+
let resources = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(field(statement, "Resource"), Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.string);
|
|
56
|
+
globalThis.test("covers the table's indexes, not only the table", () => {
|
|
57
|
+
globalThis.expect(resources).toEqual([
|
|
58
|
+
tableArn,
|
|
59
|
+
tableArn + "/index/*"
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
globalThis.test("still covers the table itself", () => {
|
|
63
|
+
globalThis.expect(resources.includes(tableArn)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
47
67
|
export {
|
|
48
68
|
ownerIndex,
|
|
49
69
|
}
|
|
@@ -51,16 +51,32 @@ describe("listAllItemsConnection — paging", () => {
|
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
testSync("a filtered read examines more rows than it serves", () => {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// The budget reads as deep as the page needs, which for a backward cut is
|
|
55
|
+
// `_upTo` rather than `_first + _from`.
|
|
56
|
+
expect(
|
|
57
|
+
code->String.includes("parts.length > 0 ? ((_backward ? _upTo : _first + _from) > 1000"),
|
|
58
|
+
)->toBe(true)
|
|
59
|
+
expect(code->String.includes("const _page = _backward ? _rest : _rest.slice(0, _first);"))
|
|
60
|
+
->toBe(true)
|
|
56
61
|
expect(code->String.includes("hasNextPage: _more || !!_next,"))->toBe(true)
|
|
57
62
|
})
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
// `before` is served by re-reading the window the cursor names; only a previous
|
|
65
|
+
// page lying in an EARLIER window is out of reach, and `last` — which needs the
|
|
66
|
+
// end of the list — stays refused.
|
|
67
|
+
testSync("backward paging is served, and its one limit is named", () => {
|
|
68
|
+
expect(code->String.includes("ctx.args.before != null && ctx.args.before !== ''"))->toBe(true)
|
|
69
|
+
expect(code->String.includes("_backward && _upTo <= 0 && _window !== null"))->toBe(true)
|
|
61
70
|
expect(code->String.includes("UnsupportedPagination"))->toBe(true)
|
|
62
71
|
})
|
|
63
72
|
|
|
73
|
+
// The door must not advertise a previous page it cannot serve — that is what
|
|
74
|
+
// put a live Prev button in front of an error.
|
|
75
|
+
testSync("only a reachable previous page is advertised", () => {
|
|
76
|
+
expect(code->String.includes("hasPreviousPage: _from > 0,"))->toBe(true)
|
|
77
|
+
expect(code->String.includes("hasPreviousPage: !!ctx.args.after,"))->toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
64
80
|
testSync("a window emptied by the filter still yields a resumable boundary cursor", () => {
|
|
65
81
|
expect(
|
|
66
82
|
code->String.includes(
|
|
@@ -37,14 +37,19 @@ globalThis.describe("listAllItemsConnection — paging", () => {
|
|
|
37
37
|
globalThis.expect(code.includes("ctx.args.after + '_' + i")).toBe(false);
|
|
38
38
|
});
|
|
39
39
|
globalThis.test("a filtered read examines more rows than it serves", () => {
|
|
40
|
-
globalThis.expect(code.includes("parts.length > 0 ? (
|
|
41
|
-
globalThis.expect(code.includes("const _page = _rest.slice(0, _first);")).toBe(true);
|
|
40
|
+
globalThis.expect(code.includes("parts.length > 0 ? ((_backward ? _upTo : _first + _from) > 1000")).toBe(true);
|
|
41
|
+
globalThis.expect(code.includes("const _page = _backward ? _rest : _rest.slice(0, _first);")).toBe(true);
|
|
42
42
|
globalThis.expect(code.includes("hasNextPage: _more || !!_next,")).toBe(true);
|
|
43
43
|
});
|
|
44
|
-
globalThis.test("backward paging
|
|
45
|
-
globalThis.expect(code.includes("ctx.args.before != null
|
|
44
|
+
globalThis.test("backward paging is served, and its one limit is named", () => {
|
|
45
|
+
globalThis.expect(code.includes("ctx.args.before != null && ctx.args.before !== ''")).toBe(true);
|
|
46
|
+
globalThis.expect(code.includes("_backward && _upTo <= 0 && _window !== null")).toBe(true);
|
|
46
47
|
globalThis.expect(code.includes("UnsupportedPagination")).toBe(true);
|
|
47
48
|
});
|
|
49
|
+
globalThis.test("only a reachable previous page is advertised", () => {
|
|
50
|
+
globalThis.expect(code.includes("hasPreviousPage: _from > 0,")).toBe(true);
|
|
51
|
+
globalThis.expect(code.includes("hasPreviousPage: !!ctx.args.after,")).toBe(false);
|
|
52
|
+
});
|
|
48
53
|
globalThis.test("a window emptied by the filter still yields a resumable boundary cursor", () => {
|
|
49
54
|
globalThis.expect(code.includes("const _boundary = _next ? util.base64Encode(JSON.stringify({ t: _next, n: -1 })) : null;")).toBe(true);
|
|
50
55
|
globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor : _boundary")).toBe(true);
|