@reventlessdev/reventless-aws 3.0.0-alpha.320 → 3.0.0-alpha.322
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 +17 -0
- package/package.json +7 -7
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +74 -38
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +20 -6
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res +56 -28
- package/src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs +25 -7
- package/tests/AppSync_RetirementNarrowingTest.res +10 -12
- package/tests/AppSync_RetirementNarrowingTest.res.mjs +4 -2
- package/tests/QueryDbOwnerIndexTableTest.res +90 -0
- package/tests/QueryDbOwnerIndexTableTest.res.mjs +70 -0
- package/tests/QueryDbResolvers_AppSyncTest.res +19 -26
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +15 -17
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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.322 (2026-08-23)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** grant the AppSync data source Query on the table's indexes ([0dc5ed2](https://github.com/ReventlessDev/reventless-core/commit/0dc5ed2e783be22af5b418c94c95fc35f4b73851))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.321 (2026-08-23)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **aws:** serve a full page from a filtered AppSync read ([b7dca5d](https://github.com/ReventlessDev/reventless-core/commit/b7dca5dac02e8c35c4848828152b90ade0f3c8ba))
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **querydb:** serve owner-scoped lists from an index instead of a filtered Scan ([ad26c28](https://github.com/ReventlessDev/reventless-core/commit/ad26c284b4b0650fa3f3b6109ed33bbfe8608f6d))
|
|
21
|
+
|
|
22
|
+
|
|
6
23
|
# 3.0.0-alpha.320 (2026-08-23)
|
|
7
24
|
|
|
8
25
|
### 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.322",
|
|
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
19
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
20
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
21
21
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
22
|
-
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.
|
|
22
|
+
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.4",
|
|
23
23
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
24
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.249",
|
|
25
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.151",
|
|
24
26
|
"@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",
|
|
27
27
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.33",
|
|
28
|
-
"@reventlessdev/reventless-
|
|
29
|
-
"@reventlessdev/reventless-
|
|
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,39 +291,75 @@ 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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
// The same
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// 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.
|
|
317
325
|
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
// here
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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, _) => ()
|
|
334
363
|
}
|
|
335
364
|
let resolverAll = makeQueryResolver(
|
|
336
365
|
~resolverName=fieldNameForAll->String.capitalize,
|
|
@@ -346,12 +375,19 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
346
375
|
~elevatedGroups,
|
|
347
376
|
~retiredField?,
|
|
348
377
|
~retiredValues?,
|
|
378
|
+
~ownerIndex?,
|
|
379
|
+
~ownerIndexSortField?,
|
|
349
380
|
)
|
|
350
381
|
} else {
|
|
351
382
|
Resolver.Functions.listAllItems
|
|
352
383
|
},
|
|
353
384
|
)
|
|
354
|
-
|
|
385
|
+
// Derived indexes are absent from the SDL (`GraphQL_FragmentGenerator` skips
|
|
386
|
+
// them), so a resolver here would attach to a field that does not exist and
|
|
387
|
+
// fail the deploy. The list resolver above is the only thing that reads one.
|
|
388
|
+
let resolversByIndex = indexes
|
|
389
|
+
->Array.filter(ic => !Reventless.ReadModel.isDerivedIndex(ic))
|
|
390
|
+
->Array.map(({index} as indexConfig) => {
|
|
355
391
|
// Name and key field both come from `GraphQL_FragmentGenerator`, which is
|
|
356
392
|
// where the SDL field this attaches to is derived. Deriving them here as
|
|
357
393
|
// 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,27 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
|
|
|
123
124
|
return Stdlib_Option.getOr(subIdField, "") === f;
|
|
124
125
|
}
|
|
125
126
|
};
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
if (retiredField !== undefined
|
|
130
|
-
|
|
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
|
+
}
|
|
131
145
|
}
|
|
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 => {
|
|
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);
|
|
147
|
+
let resolversByIndex = indexes.filter(ic => !ReadModel$Reventless.isDerivedIndex(ic)).map(indexConfig => {
|
|
134
148
|
let index = indexConfig.index;
|
|
135
149
|
let fieldName = GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName(fieldNameForSingle, index);
|
|
136
150
|
let resolverName = Stdlib_String.capitalize(fieldName);
|
|
@@ -29,20 +29,61 @@ let globalSecondaryIndexes = (indexes: array<Reventless.ReadModel.indexConfig>)
|
|
|
29
29
|
})
|
|
30
30
|
->Pulumi.Input.make
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
[
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
|
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
|
+
}
|
|
46
87
|
|
|
47
88
|
let dataSource = (name, table, api, apiRole, opts) => {
|
|
48
89
|
let _dataSourceRolePolicy = {
|
|
@@ -50,20 +91,7 @@ let dataSource = (name, table, api, apiRole, opts) => {
|
|
|
50
91
|
~name,
|
|
51
92
|
~args={
|
|
52
93
|
IAM.RolePolicy.policy: table.arn
|
|
53
|
-
->Pulumi.Output.apply(tableArn =>
|
|
54
|
-
open PolicyDocument
|
|
55
|
-
PolicyDocument.make(
|
|
56
|
-
~id=name ++ "DataSourcePolicy",
|
|
57
|
-
~statements=[
|
|
58
|
-
{
|
|
59
|
-
sid: "AllowDynamoDbActions",
|
|
60
|
-
effect: Allow,
|
|
61
|
-
actions: Action("dynamodb:*"),
|
|
62
|
-
resources: Resource(tableArn),
|
|
63
|
-
},
|
|
64
|
-
],
|
|
65
|
-
)->toJsonString
|
|
66
|
-
})
|
|
94
|
+
->Pulumi.Output.apply(tableArn => dataSourcePolicyDocument(~name, ~tableArn))
|
|
67
95
|
->Pulumi.Output.asInput,
|
|
68
96
|
role: apiRole
|
|
69
97
|
->Pulumi.Output.flatMap((role: PulumiAws.IAM.Role.t) => role.id)
|
|
@@ -38,7 +38,7 @@ function globalSecondaryIndexes(indexes) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function attributes(sortField, indexes) {
|
|
41
|
-
|
|
41
|
+
let all = [
|
|
42
42
|
[{
|
|
43
43
|
name: "id",
|
|
44
44
|
type: "S"
|
|
@@ -58,16 +58,33 @@ 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
|
+
});
|
|
71
|
+
}
|
|
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
|
+
}]));
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
function dataSource(name, table, api, apiRole, opts) {
|
|
64
86
|
new (Aws.iam.RolePolicy)(name, {
|
|
65
|
-
policy: table.arn.apply(tableArn =>
|
|
66
|
-
Sid: "AllowDynamoDbActions",
|
|
67
|
-
Effect: "Allow",
|
|
68
|
-
Action: "dynamodb:*",
|
|
69
|
-
Resource: tableArn
|
|
70
|
-
}]))),
|
|
87
|
+
policy: table.arn.apply(tableArn => dataSourcePolicyDocument(name, tableArn)),
|
|
71
88
|
role: Output$Pulumi.flatMap(apiRole, role => role.id)
|
|
72
89
|
}, opts);
|
|
73
90
|
return AppSync_DataSource$PulumiAws.makeDynamoDBDataSource(name, api, table, apiRole, opts);
|
|
@@ -94,6 +111,7 @@ function make(name, indexes, subIdField, ttl, api, apiRole, owner, opts) {
|
|
|
94
111
|
export {
|
|
95
112
|
globalSecondaryIndexes,
|
|
96
113
|
attributes,
|
|
114
|
+
dataSourcePolicyDocument,
|
|
97
115
|
dataSource,
|
|
98
116
|
make,
|
|
99
117
|
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
// The DynamoDB backend narrows retirement on every door the rule names, not
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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: !!
|
|
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("
|
|
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: !!
|
|
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("
|
|
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,90 @@
|
|
|
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
|
+
})
|
|
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
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
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";
|
|
6
|
+
import * as QueryDbStorage_DynamoDb$ReventlessAws from "../src/adapter/QueryDb/QueryDbStorage_DynamoDb.res.mjs";
|
|
7
|
+
|
|
8
|
+
let ownerIndex = {
|
|
9
|
+
index: "_owner",
|
|
10
|
+
type_: "S",
|
|
11
|
+
idField: "customerId",
|
|
12
|
+
subIdField: "id",
|
|
13
|
+
projectionType: "ALL",
|
|
14
|
+
derived: true
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
globalThis.describe("the derived owner index", () => {
|
|
18
|
+
globalThis.test("is provisioned projecting every attribute", () => {
|
|
19
|
+
let gsis = QueryDbStorage_DynamoDb$ReventlessAws.globalSecondaryIndexes([ownerIndex]);
|
|
20
|
+
globalThis.expect(gsis.map(g => [
|
|
21
|
+
g.name,
|
|
22
|
+
g.hashKey,
|
|
23
|
+
g.rangeKey,
|
|
24
|
+
g.projectionType
|
|
25
|
+
])).toEqual([[
|
|
26
|
+
"_owner",
|
|
27
|
+
"customerId",
|
|
28
|
+
"id",
|
|
29
|
+
"ALL"
|
|
30
|
+
]]);
|
|
31
|
+
});
|
|
32
|
+
globalThis.test("does not redeclare an attribute the table already has", () => {
|
|
33
|
+
let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes(undefined, [ownerIndex]).map(a => a.name);
|
|
34
|
+
globalThis.expect(names).toEqual([
|
|
35
|
+
"id",
|
|
36
|
+
"customerId"
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
globalThis.test("dedupes against the table's own sort key too", () => {
|
|
40
|
+
let newrecord = {...ownerIndex};
|
|
41
|
+
let names = QueryDbStorage_DynamoDb$ReventlessAws.attributes("placedAt", [(newrecord.subIdField = "placedAt", newrecord)]).map(a => a.name);
|
|
42
|
+
globalThis.expect(names).toEqual([
|
|
43
|
+
"id",
|
|
44
|
+
"placedAt",
|
|
45
|
+
"customerId"
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
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
|
+
|
|
67
|
+
export {
|
|
68
|
+
ownerIndex,
|
|
69
|
+
}
|
|
70
|
+
/* Not a pure module */
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
open JestGlobals
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
describe("listAllItemsConnection —
|
|
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("
|
|
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:
|
|
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("
|
|
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("
|
|
64
|
+
testSync("a window emptied by the filter still yields a resumable boundary cursor", () => {
|
|
72
65
|
expect(
|
|
73
66
|
code->String.includes(
|
|
74
|
-
"const
|
|
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 :
|
|
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 —
|
|
33
|
-
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
34
|
-
globalThis.test("
|
|
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:
|
|
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("
|
|
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("
|
|
51
|
-
globalThis.expect(code.includes("const
|
|
52
|
-
globalThis.expect(code.includes("edges.length > 0 ? edges[edges.length - 1].cursor :
|
|
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
|
|