@reventlessdev/reventless-aws 3.0.0-alpha.316 → 3.0.0-alpha.318
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 +15 -0
- package/package.json +10 -10
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +12 -3
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +16 -3
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +106 -51
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +52 -18
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res +10 -2
- package/src/adapter/QueryDb/QueryDbResolvers_Lambda.res.mjs +3 -2
- package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +31 -0
- package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs +26 -1
- package/src/adapter/Runtime/ReadModelEntryPoint.mjs +1 -0
- package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res +5 -1
- package/src/adapter/Runtime/ReadModelEntryPoint_Ops.res.mjs +1 -1
- package/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +1 -0
- package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res +3 -1
- package/src/adapter/Runtime/StateViewSliceEntryPoint_Ops.res.mjs +1 -1
- package/tests/AppSync_RetirementNarrowingTest.res +78 -0
- package/tests/AppSync_RetirementNarrowingTest.res.mjs +52 -0
- package/tests/ProjectionEntryPoint_UnionStampTest.res +88 -0
- package/tests/ProjectionEntryPoint_UnionStampTest.res.mjs +137 -0
- package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res +16 -0
- package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res.mjs +6 -0
- package/tests/integration/SvsTestSlice.res +10 -0
- package/tests/integration/SvsTestSlice.res.mjs +17 -1
- package/tests/integration/SvsTestSlice_Projection.res +3 -1
- package/tests/integration/SvsTestSlice_Projection.res.mjs +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
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.318 (2026-08-21)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
|
|
11
|
+
* **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.317 (2026-08-20)
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **aws:** stamp union member types on the deployed projection path ([140251a](https://github.com/ReventlessDev/reventless-core/commit/140251ac7c18f484be32e8683a725f6fac2b9519))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.316 (2026-08-20)
|
|
7
22
|
|
|
8
23
|
**Note:** Version bump only for package @reventlessdev/reventless-aws
|
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.318",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -10,24 +10,24 @@
|
|
|
10
10
|
"@aws-sdk/client-location": "3.970.0",
|
|
11
11
|
"@aws-sdk/client-s3": "3.970.0",
|
|
12
12
|
"@aws-sdk/s3-request-presigner": "3.970.0",
|
|
13
|
-
"sury": "11.0.0-rc.
|
|
13
|
+
"sury": "11.0.0-rc.2",
|
|
14
14
|
"uuid": "^13.0.0",
|
|
15
15
|
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.12",
|
|
16
16
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
17
17
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
18
|
-
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.1",
|
|
19
18
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
19
|
+
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.2",
|
|
20
20
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
21
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
22
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
23
|
-
"@reventlessdev/
|
|
24
|
-
"@reventlessdev/reventless-
|
|
25
|
-
"@reventlessdev/reventless-
|
|
26
|
-
"@reventlessdev/
|
|
21
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.246",
|
|
22
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.149",
|
|
23
|
+
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
24
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.33",
|
|
25
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.121",
|
|
26
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.110"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"rescript": "12.3.0",
|
|
30
|
-
"sury-ppx": "11.0.0-rc.
|
|
30
|
+
"sury-ppx": "11.0.0-rc.2"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"rescript": "12.3.0"
|
|
@@ -443,10 +443,15 @@ let dispatch = async (
|
|
|
443
443
|
items->Array.filter(it => it->argStr(subField)->Option.getOr("") == subVal)
|
|
444
444
|
| _ => items
|
|
445
445
|
}
|
|
446
|
+
// The target's own rules, applied to the target's rows — `binding` here is
|
|
447
|
+
// the target's, so `ownerAllows` / `retiredAllows` ask its questions. A
|
|
448
|
+
// nested field takes no `includeRetired` argument, so a retired row never
|
|
449
|
+
// travels through one.
|
|
450
|
+
let allowed = filtered->Array.filter(item => ownerAllows(item) && retiredAllows(item))
|
|
446
451
|
if payload.multi->Option.getOr(false) {
|
|
447
|
-
JSON.Encode.array(
|
|
452
|
+
JSON.Encode.array(allowed)
|
|
448
453
|
} else {
|
|
449
|
-
switch
|
|
454
|
+
switch allowed->Array.get(0) {
|
|
450
455
|
| Some(item) => item
|
|
451
456
|
| None => JSON.Encode.null
|
|
452
457
|
}
|
|
@@ -459,7 +464,11 @@ let dispatch = async (
|
|
|
459
464
|
let target = payload.target->Option.getOr(rm)
|
|
460
465
|
let source = payload.source->Option.getOr(JSON.Encode.null)
|
|
461
466
|
let ids = source->argStrs(payload.sourceIdsField->Option.getOr(""))
|
|
462
|
-
JSON.Encode.array(
|
|
467
|
+
JSON.Encode.array(
|
|
468
|
+
(await binding.pushdowns.byIds(~readModelName=target, ids))->Array.filter(item =>
|
|
469
|
+
ownerAllows(item) && retiredAllows(item)
|
|
470
|
+
),
|
|
471
|
+
)
|
|
463
472
|
|
|
464
473
|
| other =>
|
|
465
474
|
// Fail loudly on unmapped kinds (feature-parity guard, per the B3.2 plan).
|
|
@@ -241,7 +241,13 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
241
241
|
let target = Stdlib_Option.getOr(payload.target, rm);
|
|
242
242
|
let source = Stdlib_Option.getOr(payload.source, null);
|
|
243
243
|
let ids$2 = argStrs(source, Stdlib_Option.getOr(payload.sourceIdsField, ""));
|
|
244
|
-
return await binding.pushdowns.byIds(target, ids$2)
|
|
244
|
+
return (await binding.pushdowns.byIds(target, ids$2)).filter(item => {
|
|
245
|
+
if (ownerAllows(item)) {
|
|
246
|
+
return retiredAllows(item);
|
|
247
|
+
} else {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
245
251
|
case "resolveOne" :
|
|
246
252
|
let target$1 = Stdlib_Option.getOr(payload.target, rm);
|
|
247
253
|
let source$1 = Stdlib_Option.getOr(payload.source, null);
|
|
@@ -265,10 +271,17 @@ async function dispatch(binding, lookupBindingOpt, payload) {
|
|
|
265
271
|
} else {
|
|
266
272
|
filtered = items$2;
|
|
267
273
|
}
|
|
274
|
+
let allowed = filtered.filter(item => {
|
|
275
|
+
if (ownerAllows(item)) {
|
|
276
|
+
return retiredAllows(item);
|
|
277
|
+
} else {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
268
281
|
if (Stdlib_Option.getOr(payload.multi, false)) {
|
|
269
|
-
return
|
|
282
|
+
return allowed;
|
|
270
283
|
}
|
|
271
|
-
let item =
|
|
284
|
+
let item = allowed[0];
|
|
272
285
|
if (item !== undefined) {
|
|
273
286
|
return item;
|
|
274
287
|
} else {
|
|
@@ -522,6 +522,32 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
522
522
|
None
|
|
523
523
|
}
|
|
524
524
|
|
|
525
|
+
// The GraphQL type the cross-table fields hang off. `name` is the QueryDb's
|
|
526
|
+
// own (capitalised spec) name; the SDL calls the type by its plugin-prefixed
|
|
527
|
+
// name, and a resolver naming the other one is refused at deploy with
|
|
528
|
+
// "No field named X found on type Y".
|
|
529
|
+
let parentTypeName = switch registryEntry {
|
|
530
|
+
| Some({returnTypeName}) => returnTypeName
|
|
531
|
+
| None => name
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// A cross-table field hands back rows from the TARGET's table, so the owner
|
|
535
|
+
// and retirement rules that apply are the target's. Read from the same
|
|
536
|
+
// registry this view reads its own from — populated for every queryable in
|
|
537
|
+
// the plugin before any resolver is built.
|
|
538
|
+
let targetScope = (targetName: string) => {
|
|
539
|
+
let schema = ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.get(targetName)
|
|
540
|
+
let retired =
|
|
541
|
+
schema
|
|
542
|
+
->Option.flatMap(Reventless.StateAnnotations.getSpec)
|
|
543
|
+
->Option.flatMap(spec => spec.retired)
|
|
544
|
+
(
|
|
545
|
+
schema->Option.flatMap(s => Reventless.Owner.fieldNames(s)->Array.get(0)),
|
|
546
|
+
retired->Option.map(r => r.field),
|
|
547
|
+
retired->Option.flatMap(r => r.values),
|
|
548
|
+
)
|
|
549
|
+
}
|
|
550
|
+
|
|
525
551
|
let idResolvers = idResolverConfigs->Array.map(config => {
|
|
526
552
|
let {
|
|
527
553
|
source: {idField: sourceIdField, subId: sourceSubId, resolvedField},
|
|
@@ -533,71 +559,100 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
533
559
|
| _ => ("", "")
|
|
534
560
|
}
|
|
535
561
|
let storageResource = storageResource(~pluginName=target.pluginName, ~tableName)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
562
|
+
let (targetOwnerField, targetRetiredField, targetRetiredValues) = targetScope(tableName)
|
|
563
|
+
let (field, multi) = switch resolvedField {
|
|
564
|
+
| Single(field) => (field, false)
|
|
565
|
+
| Multi(field) => (field, true)
|
|
566
|
+
}
|
|
567
|
+
let response = Resolver.Functions.resolvedFieldResponse(
|
|
568
|
+
~multi,
|
|
569
|
+
~ownerField=targetOwnerField,
|
|
570
|
+
~elevatedGroups,
|
|
571
|
+
~retiredField=?targetRetiredField,
|
|
572
|
+
~retiredValues=?targetRetiredValues,
|
|
573
|
+
)
|
|
574
|
+
let dataSourceName =
|
|
575
|
+
DataSource.makeDynamoDBDataSourceWithTableName(
|
|
576
|
+
~name=name ++ (field->String.capitalize ++ "Resolver"),
|
|
550
577
|
~api,
|
|
551
|
-
~
|
|
552
|
-
~
|
|
553
|
-
~field=field->Pulumi.Input.make,
|
|
554
|
-
~code=switch (targetId, sourceSubId, target.subIdField) {
|
|
555
|
-
| (Id, Field(sourceSortField), Some(targetSortField)) =>
|
|
556
|
-
Resolver.Functions.resolveIdSort(~sourceIdField, ~sourceSortField, ~targetSortField)
|
|
557
|
-
| (Id, Argument(sourceSortArgument), Some(targetSortField)) =>
|
|
558
|
-
Resolver.Functions.resolveIdSortArgument(
|
|
559
|
-
~sourceIdField,
|
|
560
|
-
~sourceSortArgument,
|
|
561
|
-
~targetSortField,
|
|
562
|
-
)
|
|
563
|
-
| (Id, _, _) => Resolver.Functions.resolveId(~sourceIdField)
|
|
564
|
-
|
|
565
|
-
| (_, Field(sourceSortField), Some(targetSortField)) =>
|
|
566
|
-
Resolver.Functions.resolveIdByIndexSort(
|
|
567
|
-
~index,
|
|
568
|
-
~sourceIdField,
|
|
569
|
-
~targetIdField,
|
|
570
|
-
~sourceSortField,
|
|
571
|
-
~targetSortField,
|
|
572
|
-
)
|
|
573
|
-
| (_, Argument(sourceSortArgument), Some(targetSortField)) =>
|
|
574
|
-
Resolver.Functions.resolveIdByIndexSortArgument(
|
|
575
|
-
~index,
|
|
576
|
-
~sourceIdField,
|
|
577
|
-
~targetIdField,
|
|
578
|
-
~sourceSortArgument,
|
|
579
|
-
~targetSortField,
|
|
580
|
-
)
|
|
581
|
-
| _ => Resolver.Functions.resolveIdByIndex(~index, ~sourceIdField, ~targetIdField)
|
|
582
|
-
},
|
|
578
|
+
~tableName=storageResource.name,
|
|
579
|
+
~serviceRole=apiRole,
|
|
583
580
|
~opts,
|
|
584
|
-
)
|
|
585
|
-
|
|
581
|
+
).name->Pulumi.Output.asInput
|
|
582
|
+
|
|
583
|
+
Resolver.makeUnitJsResolver(
|
|
584
|
+
~name=name ++ field->String.capitalize,
|
|
585
|
+
~api,
|
|
586
|
+
~dataSourceName,
|
|
587
|
+
~type_=parentTypeName->Pulumi.Input.make,
|
|
588
|
+
~field=field->Pulumi.Input.make,
|
|
589
|
+
~code=switch (targetId, sourceSubId, target.subIdField) {
|
|
590
|
+
| (Id, Field(sourceSortField), Some(targetSortField)) =>
|
|
591
|
+
Resolver.Functions.resolveIdSort(
|
|
592
|
+
~sourceIdField,
|
|
593
|
+
~sourceSortField,
|
|
594
|
+
~targetSortField,
|
|
595
|
+
~response,
|
|
596
|
+
)
|
|
597
|
+
| (Id, Argument(sourceSortArgument), Some(targetSortField)) =>
|
|
598
|
+
Resolver.Functions.resolveIdSortArgument(
|
|
599
|
+
~sourceIdField,
|
|
600
|
+
~sourceSortArgument,
|
|
601
|
+
~targetSortField,
|
|
602
|
+
~response,
|
|
603
|
+
)
|
|
604
|
+
| (Id, _, _) => Resolver.Functions.resolveId(~sourceIdField, ~response)
|
|
605
|
+
|
|
606
|
+
| (_, Field(sourceSortField), Some(targetSortField)) =>
|
|
607
|
+
Resolver.Functions.resolveIdByIndexSort(
|
|
608
|
+
~index,
|
|
609
|
+
~sourceIdField,
|
|
610
|
+
~targetIdField,
|
|
611
|
+
~sourceSortField,
|
|
612
|
+
~targetSortField,
|
|
613
|
+
~response,
|
|
614
|
+
)
|
|
615
|
+
| (_, Argument(sourceSortArgument), Some(targetSortField)) =>
|
|
616
|
+
Resolver.Functions.resolveIdByIndexSortArgument(
|
|
617
|
+
~index,
|
|
618
|
+
~sourceIdField,
|
|
619
|
+
~targetIdField,
|
|
620
|
+
~sourceSortArgument,
|
|
621
|
+
~targetSortField,
|
|
622
|
+
~response,
|
|
623
|
+
)
|
|
624
|
+
| _ =>
|
|
625
|
+
Resolver.Functions.resolveIdByIndex(~index, ~sourceIdField, ~targetIdField, ~response)
|
|
626
|
+
},
|
|
627
|
+
~opts,
|
|
628
|
+
)
|
|
586
629
|
})
|
|
587
630
|
|
|
631
|
+
// `@resolvesMany` — BatchGetItem against the target's table. Runs on THIS
|
|
632
|
+
// view's data source rather than one of the target's: the tables map names
|
|
633
|
+
// the target explicitly, and every QueryDb in the plugin has already granted
|
|
634
|
+
// `dynamodb:*` on itself to the API role the data source assumes.
|
|
588
635
|
let idsResolvers = idsResolverConfigs->Array.map(config => {
|
|
589
636
|
let {source: {idsField, resolvedField}, target: {tableName} as target} = config
|
|
590
637
|
let storageResource = storageResource(~pluginName=target.pluginName, ~tableName)
|
|
638
|
+
let (targetOwnerField, targetRetiredField, targetRetiredValues) = targetScope(tableName)
|
|
591
639
|
|
|
592
640
|
Resolver.makeUnitJsResolver(
|
|
593
|
-
~name=name ++
|
|
641
|
+
~name=name ++ resolvedField->String.capitalize,
|
|
594
642
|
~api,
|
|
595
643
|
~dataSourceName,
|
|
596
|
-
~type_=
|
|
644
|
+
~type_=parentTypeName->Pulumi.Input.make,
|
|
597
645
|
~field=resolvedField->Pulumi.Input.make,
|
|
598
646
|
~code=generateCode(
|
|
599
647
|
~storageResource,
|
|
600
|
-
~template=Resolver.Functions.resolveIds(
|
|
648
|
+
~template=Resolver.Functions.resolveIds(
|
|
649
|
+
~idsField,
|
|
650
|
+
~sortField=target.subIdField,
|
|
651
|
+
~ownerField=?targetOwnerField,
|
|
652
|
+
~retiredField=?targetRetiredField,
|
|
653
|
+
~retiredValues=?targetRetiredValues,
|
|
654
|
+
~elevatedGroups,
|
|
655
|
+
),
|
|
601
656
|
),
|
|
602
657
|
~opts,
|
|
603
658
|
)
|
|
@@ -171,10 +171,22 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
|
|
|
171
171
|
} else {
|
|
172
172
|
resolverRefs = undefined;
|
|
173
173
|
}
|
|
174
|
+
let parentTypeName = registryEntry !== undefined ? registryEntry.returnTypeName : name$1;
|
|
175
|
+
let targetScope = targetName => {
|
|
176
|
+
let schema = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[targetName];
|
|
177
|
+
let retired = Stdlib_Option.flatMap(Stdlib_Option.flatMap(schema, StateAnnotations$Reventless.getSpec), spec => spec.retired);
|
|
178
|
+
return [
|
|
179
|
+
Stdlib_Option.flatMap(schema, s => Owner$Reventless.fieldNames(s)[0]),
|
|
180
|
+
Stdlib_Option.map(retired, r => r.field),
|
|
181
|
+
Stdlib_Option.flatMap(retired, r => r.values)
|
|
182
|
+
];
|
|
183
|
+
};
|
|
174
184
|
let idResolvers = idResolverConfigs.map(config => {
|
|
175
185
|
let target = config.target;
|
|
176
186
|
let targetId = target.idField;
|
|
187
|
+
let tableName = target.tableName;
|
|
177
188
|
let match = config.source;
|
|
189
|
+
let resolvedField = match.resolvedField;
|
|
178
190
|
let sourceSubId = match.subId;
|
|
179
191
|
let sourceIdField = match.idField;
|
|
180
192
|
let match$1;
|
|
@@ -197,40 +209,62 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
|
|
|
197
209
|
}
|
|
198
210
|
let targetIdField = match$1[1];
|
|
199
211
|
let index$1 = match$1[0];
|
|
200
|
-
let storageResource$1 = storageResource(target.pluginName,
|
|
201
|
-
let
|
|
212
|
+
let storageResource$1 = storageResource(target.pluginName, tableName);
|
|
213
|
+
let match$2 = targetScope(tableName);
|
|
214
|
+
let match$3;
|
|
215
|
+
match$3 = resolvedField.TAG === "Single" ? [
|
|
216
|
+
resolvedField._0,
|
|
217
|
+
false
|
|
218
|
+
] : [
|
|
219
|
+
resolvedField._0,
|
|
220
|
+
true
|
|
221
|
+
];
|
|
222
|
+
let field = match$3[0];
|
|
223
|
+
let response = AppSync_Resolver_Functions$PulumiAws.resolvedFieldResponse(match$3[1], match$2[0], elevatedGroups, match$2[1], match$2[2]);
|
|
202
224
|
let dataSourceName = AppSync_DataSource$PulumiAws.makeDynamoDBDataSourceWithTableName(name$1 + (Stdlib_String.capitalize(field) + "Resolver"), api, storageResource$1.name, apiRole, opts).name;
|
|
203
|
-
let match$
|
|
225
|
+
let match$4 = target.subIdField;
|
|
204
226
|
let tmp;
|
|
205
227
|
let exit = 0;
|
|
228
|
+
let exit$1 = 0;
|
|
206
229
|
if (typeof targetId !== "object") {
|
|
207
|
-
tmp = typeof sourceSubId !== "object" ? AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField) : (
|
|
230
|
+
tmp = typeof sourceSubId !== "object" ? AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField, response) : (
|
|
208
231
|
sourceSubId.TAG === "Field" ? (
|
|
209
|
-
match$
|
|
232
|
+
match$4 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdSort(sourceIdField, sourceSubId._0, match$4, response) : AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField, response)
|
|
210
233
|
) : (
|
|
211
|
-
match$
|
|
234
|
+
match$4 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdSortArgument(sourceIdField, sourceSubId._0, match$4, response) : AppSync_Resolver_Functions$PulumiAws.resolveId(sourceIdField, response)
|
|
212
235
|
)
|
|
213
236
|
);
|
|
214
237
|
} else {
|
|
215
|
-
exit =
|
|
238
|
+
exit$1 = 2;
|
|
239
|
+
}
|
|
240
|
+
if (exit$1 === 2) {
|
|
241
|
+
if (typeof sourceSubId !== "object") {
|
|
242
|
+
exit = 1;
|
|
243
|
+
} else if (sourceSubId.TAG === "Field") {
|
|
244
|
+
if (match$4 !== undefined) {
|
|
245
|
+
tmp = AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSort(index$1, sourceIdField, sourceSubId._0, targetIdField, match$4, response);
|
|
246
|
+
} else {
|
|
247
|
+
exit = 1;
|
|
248
|
+
}
|
|
249
|
+
} else if (match$4 !== undefined) {
|
|
250
|
+
tmp = AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSortArgument(index$1, sourceIdField, sourceSubId._0, targetIdField, match$4, response);
|
|
251
|
+
} else {
|
|
252
|
+
exit = 1;
|
|
253
|
+
}
|
|
216
254
|
}
|
|
217
255
|
if (exit === 1) {
|
|
218
|
-
tmp =
|
|
219
|
-
sourceSubId.TAG === "Field" ? (
|
|
220
|
-
match$2 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSort(index$1, sourceIdField, sourceSubId._0, targetIdField, match$2) : AppSync_Resolver_Functions$PulumiAws.resolveIdByIndex(index$1, sourceIdField, targetIdField)
|
|
221
|
-
) : (
|
|
222
|
-
match$2 !== undefined ? AppSync_Resolver_Functions$PulumiAws.resolveIdByIndexSortArgument(index$1, sourceIdField, sourceSubId._0, targetIdField, match$2) : AppSync_Resolver_Functions$PulumiAws.resolveIdByIndex(index$1, sourceIdField, targetIdField)
|
|
223
|
-
)
|
|
224
|
-
);
|
|
256
|
+
tmp = AppSync_Resolver_Functions$PulumiAws.resolveIdByIndex(index$1, sourceIdField, targetIdField, response);
|
|
225
257
|
}
|
|
226
|
-
return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName,
|
|
258
|
+
return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName, parentTypeName, field, tmp, opts);
|
|
227
259
|
});
|
|
228
260
|
let idsResolvers = idsResolverConfigs.map(config => {
|
|
229
261
|
let target = config.target;
|
|
262
|
+
let tableName = target.tableName;
|
|
230
263
|
let match = config.source;
|
|
231
|
-
let
|
|
232
|
-
let storageResource$1 = storageResource(target.pluginName,
|
|
233
|
-
|
|
264
|
+
let resolvedField = match.resolvedField;
|
|
265
|
+
let storageResource$1 = storageResource(target.pluginName, tableName);
|
|
266
|
+
let match$1 = targetScope(tableName);
|
|
267
|
+
return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(resolvedField), api, dataSourceName, parentTypeName, resolvedField, generateCode(storageResource$1, AppSync_Resolver_Functions$PulumiAws.resolveIds(match.idsField, target.subIdField, match$1[0], match$1[1], match$1[2], elevatedGroups)), opts);
|
|
234
268
|
});
|
|
235
269
|
let mainResolvers = resolverByIdMultiple !== undefined ? (
|
|
236
270
|
resolverByIds !== undefined ? [
|
|
@@ -231,6 +231,14 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
231
231
|
}
|
|
232
232
|
})
|
|
233
233
|
|
|
234
|
+
// The GraphQL type the cross-table fields hang off — the plugin-prefixed SDL
|
|
235
|
+
// name, not the QueryDb's own. A resolver naming the other one is refused at
|
|
236
|
+
// deploy with "No field named X found on type Y".
|
|
237
|
+
let parentTypeName = switch registryEntry {
|
|
238
|
+
| Some({returnTypeName}) => returnTypeName
|
|
239
|
+
| None => name
|
|
240
|
+
}
|
|
241
|
+
|
|
234
242
|
// @resolves — single cross-table field resolver on this (parent) type. The
|
|
235
243
|
// target's binding key is its capitalized spec name (mirrors how the binding
|
|
236
244
|
// registry is keyed); the parent object flows via ctx.source.
|
|
@@ -259,7 +267,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
259
267
|
~name=name ++ field->String.capitalize,
|
|
260
268
|
~api,
|
|
261
269
|
~dataSourceName,
|
|
262
|
-
~type_=
|
|
270
|
+
~type_=parentTypeName->Pulumi.Input.make,
|
|
263
271
|
~field=field->Pulumi.Input.make,
|
|
264
272
|
~code=invokeFieldTemplate(~sourceType=name, ~kind="resolveOne", ~target=targetKey, ~extra),
|
|
265
273
|
~opts,
|
|
@@ -275,7 +283,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
275
283
|
~name=name ++ resolvedField->String.capitalize,
|
|
276
284
|
~api,
|
|
277
285
|
~dataSourceName,
|
|
278
|
-
~type_=
|
|
286
|
+
~type_=parentTypeName->Pulumi.Input.make,
|
|
279
287
|
~field=resolvedField->Pulumi.Input.make,
|
|
280
288
|
~code=invokeFieldTemplate(~sourceType=name, ~kind="resolveMany", ~target=targetKey, ~extra),
|
|
281
289
|
~opts,
|
|
@@ -107,6 +107,7 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
|
|
|
107
107
|
return mkResolver(resolverName, field, "index", index, undefined, undefined);
|
|
108
108
|
}
|
|
109
109
|
});
|
|
110
|
+
let parentTypeName = registryEntry !== undefined ? registryEntry.returnTypeName : name$1;
|
|
110
111
|
let idResolvers = idResolverConfigs.map(config => {
|
|
111
112
|
let target = config.target;
|
|
112
113
|
let match = config.source;
|
|
@@ -139,14 +140,14 @@ function make(name, api, param, dataSourceName, indexes, subIdField, idResolverC
|
|
|
139
140
|
let extra = `\n sourceIdField: '` + match.idField + `',\n multi: ` + (
|
|
140
141
|
match$1[1] ? "true" : "false"
|
|
141
142
|
) + `,` + targetFrag + subIdFrag;
|
|
142
|
-
return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName,
|
|
143
|
+
return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(field), api, dataSourceName, parentTypeName, field, invokeFieldTemplate(name$1, "resolveOne", targetKey, extra), opts);
|
|
143
144
|
});
|
|
144
145
|
let idsResolvers = idsResolverConfigs.map(config => {
|
|
145
146
|
let match = config.source;
|
|
146
147
|
let resolvedField = match.resolvedField;
|
|
147
148
|
let targetKey = Stdlib_String.capitalize(config.target.tableName);
|
|
148
149
|
let extra = `\n sourceIdsField: '` + match.idsField + `',`;
|
|
149
|
-
return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(resolvedField), api, dataSourceName,
|
|
150
|
+
return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + Stdlib_String.capitalize(resolvedField), api, dataSourceName, parentTypeName, resolvedField, invokeFieldTemplate(name$1, "resolveMany", targetKey, extra), opts);
|
|
150
151
|
});
|
|
151
152
|
return [
|
|
152
153
|
byId,
|
|
@@ -55,6 +55,37 @@ external withLiveUpdates: (
|
|
|
55
55
|
liveConfig,
|
|
56
56
|
) => ReventlessCore.QueryDb_Adapter.operations = "withLiveUpdates"
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
Stamps every union value in a saved row with the GraphQL member type it is.
|
|
60
|
+
|
|
61
|
+
`QueryDb_Operations.Make` does this for the typed path — the local platform and
|
|
62
|
+
the storage tests — but the deployed projection Lambdas do not go through that
|
|
63
|
+
functor: they assemble JSON-level operations here. Without this the stamp never
|
|
64
|
+
ran on AWS, every union member resolved to null, and the null took its
|
|
65
|
+
non-nullable parent, so rows vanished from a view rather than erroring.
|
|
66
|
+
|
|
67
|
+
`stateSchema` is `None` for a spec module that predates it, which leaves rows
|
|
68
|
+
exactly as they were.
|
|
69
|
+
*/
|
|
70
|
+
let withUnionMemberTypes = (
|
|
71
|
+
base: ReventlessCore.QueryDb_Adapter.operations,
|
|
72
|
+
~stateSchema: option<S.t<unknown>>,
|
|
73
|
+
): ReventlessCore.QueryDb_Adapter.operations =>
|
|
74
|
+
switch stateSchema {
|
|
75
|
+
| None => base
|
|
76
|
+
| Some(schema) =>
|
|
77
|
+
let stamp = (state: JSON.t) => {
|
|
78
|
+
Reventless.TaggedUnion.stampInto(~schema, state)
|
|
79
|
+
state
|
|
80
|
+
}
|
|
81
|
+
{
|
|
82
|
+
...base,
|
|
83
|
+
save: (id, state, saveMode, ttl) => base.save(id, stamp(state), saveMode, ttl),
|
|
84
|
+
saveBatch: items =>
|
|
85
|
+
base.saveBatch(items->Array.map(((id, state, ttl)) => (id, stamp(state), ttl))),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
let makeQueryDbOps = (
|
|
59
90
|
~queryDbTableName: string,
|
|
60
91
|
~pgConnection: option<PgConnection.connectionConfig>,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
4
5
|
import * as StateTopicPublishMjs from "./StateTopicPublish.mjs";
|
|
5
6
|
import * as QueryDbEntryPoint_Ops$ReventlessAws from "./QueryDbEntryPoint_Ops.res.mjs";
|
|
6
7
|
import * as QueryDbStorage_Postgres_Runtime$ReventlessAws from "../QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
|
|
@@ -17,6 +18,29 @@ function withLiveUpdates(prim0, prim1) {
|
|
|
17
18
|
return StateTopicPublishMjs.withLiveUpdates(prim0, prim1);
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function withUnionMemberTypes(base, stateSchema) {
|
|
22
|
+
if (stateSchema !== undefined) {
|
|
23
|
+
return {
|
|
24
|
+
load: base.load,
|
|
25
|
+
loadStream: base.loadStream,
|
|
26
|
+
save: (id, state, saveMode, ttl) => base.save(id, (TaggedUnion$Reventless.stampInto(stateSchema, state), state), saveMode, ttl),
|
|
27
|
+
saveBatch: items => base.saveBatch(items.map(param => {
|
|
28
|
+
let state = param[1];
|
|
29
|
+
return [
|
|
30
|
+
param[0],
|
|
31
|
+
(TaggedUnion$Reventless.stampInto(stateSchema, state), state),
|
|
32
|
+
param[2]
|
|
33
|
+
];
|
|
34
|
+
})),
|
|
35
|
+
count: base.count,
|
|
36
|
+
delete: base.delete,
|
|
37
|
+
deleteBatch: base.deleteBatch
|
|
38
|
+
};
|
|
39
|
+
} else {
|
|
40
|
+
return base;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
function makeQueryDbOps(queryDbTableName, pgConnection, stateTopicName, indexes, subIdField) {
|
|
21
45
|
if (pgConnection !== undefined) {
|
|
22
46
|
return StateTopicPublishMjs.withLiveUpdates(QueryDbStorage_Postgres_Runtime$ReventlessAws.opsFor(pgConnection, queryDbTableName, indexes, subIdField), {
|
|
@@ -34,6 +58,7 @@ export {
|
|
|
34
58
|
indexesOf,
|
|
35
59
|
subIdFieldOf,
|
|
36
60
|
withLiveUpdates,
|
|
61
|
+
withUnionMemberTypes,
|
|
37
62
|
makeQueryDbOps,
|
|
38
63
|
}
|
|
39
|
-
/*
|
|
64
|
+
/* TaggedUnion-Reventless Not a pure module */
|
|
@@ -47,6 +47,7 @@ async function buildAllHandlers() {
|
|
|
47
47
|
const operations = Ops.buildOperations(entry, {
|
|
48
48
|
config: specModule.config,
|
|
49
49
|
subIdConfig: specModule.subIdConfig,
|
|
50
|
+
stateSchema: specModule.stateSchema,
|
|
50
51
|
});
|
|
51
52
|
const callback = readModelCallbackMake(specModule)(mappingsModule)({
|
|
52
53
|
ReadModelSpec: specModule,
|
|
@@ -90,6 +90,8 @@ let withInjectedId = (
|
|
|
90
90
|
type specInfo = {
|
|
91
91
|
config: option<ProjectionEntryPoint_Ops.specConfig>,
|
|
92
92
|
subIdConfig: option<Reventless.ReadModel.subIdConfig<JSON.t>>,
|
|
93
|
+
// Read off the runtime-loaded spec module; `None` on a module that predates it.
|
|
94
|
+
stateSchema: option<S.t<unknown>>,
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
let buildOperations = (
|
|
@@ -102,7 +104,9 @@ let buildOperations = (
|
|
|
102
104
|
~stateTopicName=entry.stateTopicName,
|
|
103
105
|
~indexes=ProjectionEntryPoint_Ops.indexesOf(spec.config),
|
|
104
106
|
~subIdField=ProjectionEntryPoint_Ops.subIdFieldOf(spec.subIdConfig),
|
|
105
|
-
)
|
|
107
|
+
)
|
|
108
|
+
->ProjectionEntryPoint_Ops.withUnionMemberTypes(~stateSchema=spec.stateSchema)
|
|
109
|
+
->withInjectedId
|
|
106
110
|
|
|
107
111
|
let makeRegisteredHandler = (
|
|
108
112
|
entry: handlerEntry,
|
|
@@ -60,7 +60,7 @@ function withInjectedId(base) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function buildOperations(entry, spec) {
|
|
63
|
-
return withInjectedId(ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(spec.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(spec.subIdConfig)));
|
|
63
|
+
return withInjectedId(ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(spec.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(spec.subIdConfig)), spec.stateSchema));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function makeRegisteredHandler(entry, handleJsonEvents) {
|
|
@@ -176,6 +176,8 @@ type sliceModules<'event> = {
|
|
|
176
176
|
>,
|
|
177
177
|
config: option<ProjectionEntryPoint_Ops.specConfig>,
|
|
178
178
|
subIdConfig: option<Reventless.ReadModel.subIdConfig<JSON.t>>,
|
|
179
|
+
// Same union stamp the read-model path needs; a slice's state can hold one too.
|
|
180
|
+
stateSchema: option<S.t<unknown>>,
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
let makeRegisteredHandler = (
|
|
@@ -188,7 +190,7 @@ let makeRegisteredHandler = (
|
|
|
188
190
|
~stateTopicName=entry.stateTopicName,
|
|
189
191
|
~indexes=ProjectionEntryPoint_Ops.indexesOf(modules.config),
|
|
190
192
|
~subIdField=ProjectionEntryPoint_Ops.subIdFieldOf(modules.subIdConfig),
|
|
191
|
-
)
|
|
193
|
+
)->ProjectionEntryPoint_Ops.withUnionMemberTypes(~stateSchema=modules.stateSchema)
|
|
192
194
|
{
|
|
193
195
|
handler: StreamRoutedEntryPoint_Ops.toStreamHandler(
|
|
194
196
|
makeJsonEventsHandler(
|
|
@@ -84,7 +84,7 @@ function makeJsonEventsHandler(sliceName, eventSchema, project, queryDbOps, subI
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function makeRegisteredHandler(entry, modules) {
|
|
87
|
-
let queryDbOps = ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(modules.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(modules.subIdConfig));
|
|
87
|
+
let queryDbOps = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(ProjectionEntryPoint_Ops$ReventlessAws.makeQueryDbOps(entry.queryDbTableName, entry.pgConnection, entry.stateTopicName, ProjectionEntryPoint_Ops$ReventlessAws.indexesOf(modules.config), ProjectionEntryPoint_Ops$ReventlessAws.subIdFieldOf(modules.subIdConfig)), modules.stateSchema);
|
|
88
88
|
return {
|
|
89
89
|
handler: StreamRoutedEntryPoint_Ops$ReventlessAws.toStreamHandler(makeJsonEventsHandler(modules.name, modules.consumedEventSchema, modules.project, queryDbOps, modules.subIdConfig)),
|
|
90
90
|
comp: `StateViewSlice(` + modules.name + `)`
|
|
@@ -308,3 +308,81 @@ describe("the unowned stub is callable where the door calls it", () => {
|
|
|
308
308
|
))->Expect.toEqual((true, true))
|
|
309
309
|
})
|
|
310
310
|
})
|
|
311
|
+
|
|
312
|
+
// ── The cross-table doors (`@resolves` / `@resolvesMany`) ────────────────────
|
|
313
|
+
//
|
|
314
|
+
// A field that follows a foreign key hands back the TARGET's rows, so the
|
|
315
|
+
// target's rules narrow them. The field takes no `includeRetired` argument, so
|
|
316
|
+
// `_wantsRetired` is never true through one — `{list}Refs` is the door that
|
|
317
|
+
// names a row the archive took.
|
|
318
|
+
|
|
319
|
+
describe("the cross-table batch door", () => {
|
|
320
|
+
let code = F.resolveIds(
|
|
321
|
+
~idsField="productIds",
|
|
322
|
+
~sortField=None,
|
|
323
|
+
~retiredField="shelfStatus",
|
|
324
|
+
~retiredValues=["Archived"],
|
|
325
|
+
~elevatedGroups=elevated,
|
|
326
|
+
)("ProductsTable")
|
|
327
|
+
|
|
328
|
+
// BatchGetItem answers `{data: {<table>: [...]}}`; returning `ctx.result`
|
|
329
|
+
// handed the whole envelope to a field typed as a list, which fails at
|
|
330
|
+
// execution for every caller that selects it.
|
|
331
|
+
testSync("returns the rows out of the batch envelope", () =>
|
|
332
|
+
expect(code->String.includes("ctx.result?.data?.['ProductsTable']"))->Expect.toBe(true)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
testSync("drops retired rows from the returned array", () =>
|
|
336
|
+
expect(code->String.includes("item !== null && _live(item)"))->Expect.toBe(true)
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
// An empty id array used to fall through to a GetItem on the PARENT's own id
|
|
340
|
+
// against the target's table — a read that answers a shape the field cannot be.
|
|
341
|
+
testSync("short-circuits an empty id array instead of reading", () =>
|
|
342
|
+
expect((
|
|
343
|
+
code->String.includes("if (idList.length === 0) return runtime.earlyReturn([]);"),
|
|
344
|
+
code->String.includes("operation: 'GetItem'"),
|
|
345
|
+
))->toEqual((true, false))
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
testSync("is unchanged for a target that declares neither rule", () => {
|
|
349
|
+
let plain = F.resolveIds(~idsField="productIds", ~sortField=None)("ProductsTable")
|
|
350
|
+
expect((plain->String.includes("_owns"), plain->String.includes("_live")))->toEqual((
|
|
351
|
+
false,
|
|
352
|
+
false,
|
|
353
|
+
))
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe("the cross-table single door", () => {
|
|
358
|
+
let response = (~multi) =>
|
|
359
|
+
F.resolvedFieldResponse(
|
|
360
|
+
~multi,
|
|
361
|
+
~ownerField=Some("customerId"),
|
|
362
|
+
~elevatedGroups=elevated,
|
|
363
|
+
~retiredField="shelfStatus",
|
|
364
|
+
~retiredValues=["Archived"],
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
testSync("narrows the one row it hands back", () =>
|
|
368
|
+
expect(response(~multi=false)->String.includes("_owns(_row) && _live(_row) ? _row : null"))
|
|
369
|
+
->Expect.toBe(true)
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
testSync("narrows every row of the list form", () =>
|
|
373
|
+
expect(
|
|
374
|
+
response(~multi=true)->String.includes("filter(_row => _owns(_row) && _live(_row))"),
|
|
375
|
+
)->Expect.toBe(true)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
// The list form used to answer with the first row alone, whatever the SDL said.
|
|
379
|
+
testSync("the list form returns the list", () =>
|
|
380
|
+
expect(response(~multi=true)->String.includes("ctx.result.items[0]"))->Expect.toBe(false)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
testSync("a target with neither rule keeps the plain response", () => {
|
|
384
|
+
let plain = F.resolvedFieldResponse(~multi=false, ~ownerField=None, ~elevatedGroups=elevated)
|
|
385
|
+
expect((plain->String.includes("_owns"), plain->String.includes("ctx.result.items[0]")))
|
|
386
|
+
->toEqual((false, true))
|
|
387
|
+
})
|
|
388
|
+
})
|
|
@@ -206,6 +206,58 @@ globalThis.describe("the unowned stub is callable where the door calls it", () =
|
|
|
206
206
|
});
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
+
globalThis.describe("the cross-table batch door", () => {
|
|
210
|
+
let code = AppSync_Resolver_Functions$PulumiAws.resolveIds("productIds", undefined, undefined, "shelfStatus", ["Archived"], elevated)("ProductsTable");
|
|
211
|
+
globalThis.test("returns the rows out of the batch envelope", () => {
|
|
212
|
+
globalThis.expect(code.includes("ctx.result?.data?.['ProductsTable']")).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
globalThis.test("drops retired rows from the returned array", () => {
|
|
215
|
+
globalThis.expect(code.includes("item !== null && _live(item)")).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
globalThis.test("short-circuits an empty id array instead of reading", () => {
|
|
218
|
+
globalThis.expect([
|
|
219
|
+
code.includes("if (idList.length === 0) return runtime.earlyReturn([]);"),
|
|
220
|
+
code.includes("operation: 'GetItem'")
|
|
221
|
+
]).toEqual([
|
|
222
|
+
true,
|
|
223
|
+
false
|
|
224
|
+
]);
|
|
225
|
+
});
|
|
226
|
+
globalThis.test("is unchanged for a target that declares neither rule", () => {
|
|
227
|
+
let plain = AppSync_Resolver_Functions$PulumiAws.resolveIds("productIds", undefined, undefined, undefined, undefined, undefined)("ProductsTable");
|
|
228
|
+
globalThis.expect([
|
|
229
|
+
plain.includes("_owns"),
|
|
230
|
+
plain.includes("_live")
|
|
231
|
+
]).toEqual([
|
|
232
|
+
false,
|
|
233
|
+
false
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
globalThis.describe("the cross-table single door", () => {
|
|
239
|
+
let response = multi => AppSync_Resolver_Functions$PulumiAws.resolvedFieldResponse(multi, "customerId", elevated, "shelfStatus", ["Archived"]);
|
|
240
|
+
globalThis.test("narrows the one row it hands back", () => {
|
|
241
|
+
globalThis.expect(response(false).includes("_owns(_row) && _live(_row) ? _row : null")).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
globalThis.test("narrows every row of the list form", () => {
|
|
244
|
+
globalThis.expect(response(true).includes("filter(_row => _owns(_row) && _live(_row))")).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
globalThis.test("the list form returns the list", () => {
|
|
247
|
+
globalThis.expect(response(true).includes("ctx.result.items[0]")).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
globalThis.test("a target with neither rule keeps the plain response", () => {
|
|
250
|
+
let plain = AppSync_Resolver_Functions$PulumiAws.resolvedFieldResponse(false, undefined, elevated, undefined, undefined);
|
|
251
|
+
globalThis.expect([
|
|
252
|
+
plain.includes("_owns"),
|
|
253
|
+
plain.includes("ctx.result.items[0]")
|
|
254
|
+
]).toEqual([
|
|
255
|
+
false,
|
|
256
|
+
true
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
209
261
|
let F;
|
|
210
262
|
|
|
211
263
|
export {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The deployed projection Lambdas assemble JSON-level QueryDb operations rather
|
|
2
|
+
// than going through `QueryDb_Operations.Make`, so the union `__typename` stamp
|
|
3
|
+
// that functor applies never ran on AWS. Every union member resolved to null and
|
|
4
|
+
// took its non-nullable parent with it — rows vanished from a view instead of
|
|
5
|
+
// erroring, on every read door, with nothing reporting it.
|
|
6
|
+
//
|
|
7
|
+
// The typed path was covered from the start; this side was not, which is the
|
|
8
|
+
// whole reason it shipped.
|
|
9
|
+
|
|
10
|
+
open JestGlobals
|
|
11
|
+
|
|
12
|
+
@schema
|
|
13
|
+
type geolocation =
|
|
14
|
+
| Pending({requestedFor: string})
|
|
15
|
+
| Located({lat: float, lng: float})
|
|
16
|
+
|
|
17
|
+
let geolocationSchema = Reventless.TaggedUnion.named(~name="Geolocation", geolocationSchema)
|
|
18
|
+
|
|
19
|
+
@schema
|
|
20
|
+
type rowState = {customerId: string, geolocation: geolocation}
|
|
21
|
+
|
|
22
|
+
// A recording stand-in for the storage the runtime would wrap.
|
|
23
|
+
let capture = (): (ReventlessCore.QueryDb_Adapter.operations, array<JSON.t>) => {
|
|
24
|
+
let saved: array<JSON.t> = []
|
|
25
|
+
let base: ReventlessCore.QueryDb_Adapter.operations = {
|
|
26
|
+
load: _ => Promise.resolve(Ok([])),
|
|
27
|
+
loadStream: _ => Stream.empty,
|
|
28
|
+
save: (_id, state, _saveMode, _ttl) => {
|
|
29
|
+
saved->Array.push(state)
|
|
30
|
+
Promise.resolve(Ok())
|
|
31
|
+
},
|
|
32
|
+
saveBatch: items => {
|
|
33
|
+
items->Array.forEach(((_, state, _)) => saved->Array.push(state))
|
|
34
|
+
Promise.resolve(Ok())
|
|
35
|
+
},
|
|
36
|
+
count: (_, _, _) => Promise.resolve(Ok(0)),
|
|
37
|
+
delete: (_, _) => Promise.resolve(Ok()),
|
|
38
|
+
deleteBatch: _ => Promise.resolve(Ok()),
|
|
39
|
+
}
|
|
40
|
+
(base, saved)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let memberOf = (json: JSON.t) =>
|
|
44
|
+
json
|
|
45
|
+
->JSON.Decode.object
|
|
46
|
+
->Option.flatMap(o => o->Dict.get("geolocation"))
|
|
47
|
+
->Option.flatMap(JSON.Decode.object)
|
|
48
|
+
->Option.flatMap(o => o->Dict.get("__typename"))
|
|
49
|
+
->Option.flatMap(JSON.Decode.string)
|
|
50
|
+
|
|
51
|
+
let row = (): JSON.t =>
|
|
52
|
+
({customerId: "c1", geolocation: Located({lat: 1.0, lng: 2.0})}: rowState)
|
|
53
|
+
->ReventlessCore.Message.encode(rowStateSchema)
|
|
54
|
+
|
|
55
|
+
describe("ProjectionEntryPoint_Ops.withUnionMemberTypes", () => {
|
|
56
|
+
testPromise("stamps the member type on save", async () => {
|
|
57
|
+
let (base, saved) = capture()
|
|
58
|
+
let ops =
|
|
59
|
+
base->ProjectionEntryPoint_Ops.withUnionMemberTypes(
|
|
60
|
+
~stateSchema=Some(rowStateSchema->S.castToUnknown),
|
|
61
|
+
)
|
|
62
|
+
let _ = await ops.save("c1", row(), ReventlessCore.QueryDb.Any, None)
|
|
63
|
+
expect(saved->Array.get(0)->Option.flatMap(memberOf))->toEqual(Some("GeolocationLocated"))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// saveBatch is the path a multi-event projection takes, and it was missing the
|
|
67
|
+
// stamp for the same reason save was.
|
|
68
|
+
testPromise("stamps every row in a batch", async () => {
|
|
69
|
+
let (base, saved) = capture()
|
|
70
|
+
let ops =
|
|
71
|
+
base->ProjectionEntryPoint_Ops.withUnionMemberTypes(
|
|
72
|
+
~stateSchema=Some(rowStateSchema->S.castToUnknown),
|
|
73
|
+
)
|
|
74
|
+
let _ = await ops.saveBatch([("c1", row(), None), ("c2", row(), None)])
|
|
75
|
+
expect(saved->Array.map(memberOf))->toEqual([
|
|
76
|
+
Some("GeolocationLocated"),
|
|
77
|
+
Some("GeolocationLocated"),
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// A spec module without a stateSchema leaves rows untouched rather than throwing.
|
|
82
|
+
testPromise("passes rows through when the spec has no schema", async () => {
|
|
83
|
+
let (base, saved) = capture()
|
|
84
|
+
let ops = base->ProjectionEntryPoint_Ops.withUnionMemberTypes(~stateSchema=None)
|
|
85
|
+
let _ = await ops.save("c1", row(), ReventlessCore.QueryDb.Any, None)
|
|
86
|
+
expect(saved->Array.get(0)->Option.flatMap(memberOf))->toEqual(None)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Sury from "sury";
|
|
4
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Stream from "effect/Stream";
|
|
7
|
+
import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
|
|
8
|
+
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
9
|
+
import * as ProjectionEntryPoint_Ops$ReventlessAws from "../src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs";
|
|
10
|
+
|
|
11
|
+
let geolocationSchema = Sury.union([
|
|
12
|
+
Sury.$schema(s => ({
|
|
13
|
+
TAG: "Pending",
|
|
14
|
+
requestedFor: s.m(Sury.string)
|
|
15
|
+
})),
|
|
16
|
+
Sury.$schema(s => ({
|
|
17
|
+
TAG: "Located",
|
|
18
|
+
lat: s.m(Sury.float),
|
|
19
|
+
lng: s.m(Sury.float)
|
|
20
|
+
}))
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
let geolocationSchema$1 = TaggedUnion$Reventless.named("Geolocation", geolocationSchema);
|
|
24
|
+
|
|
25
|
+
let rowStateSchema = Sury.$schema(s => ({
|
|
26
|
+
customerId: s.m(Sury.string),
|
|
27
|
+
geolocation: s.m(geolocationSchema$1)
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
function capture() {
|
|
31
|
+
let saved = [];
|
|
32
|
+
let base_load = param => Promise.resolve({
|
|
33
|
+
TAG: "Ok",
|
|
34
|
+
_0: []
|
|
35
|
+
});
|
|
36
|
+
let base_loadStream = param => Stream.empty;
|
|
37
|
+
let base_save = (_id, state, _saveMode, _ttl) => {
|
|
38
|
+
saved.push(state);
|
|
39
|
+
return Promise.resolve({
|
|
40
|
+
TAG: "Ok",
|
|
41
|
+
_0: undefined
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
let base_saveBatch = items => {
|
|
45
|
+
items.forEach(param => {
|
|
46
|
+
saved.push(param[1]);
|
|
47
|
+
});
|
|
48
|
+
return Promise.resolve({
|
|
49
|
+
TAG: "Ok",
|
|
50
|
+
_0: undefined
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
let base_count = (param, param$1, param$2) => Promise.resolve({
|
|
54
|
+
TAG: "Ok",
|
|
55
|
+
_0: 0
|
|
56
|
+
});
|
|
57
|
+
let base_delete = (param, param$1) => Promise.resolve({
|
|
58
|
+
TAG: "Ok",
|
|
59
|
+
_0: undefined
|
|
60
|
+
});
|
|
61
|
+
let base_deleteBatch = param => Promise.resolve({
|
|
62
|
+
TAG: "Ok",
|
|
63
|
+
_0: undefined
|
|
64
|
+
});
|
|
65
|
+
let base = {
|
|
66
|
+
load: base_load,
|
|
67
|
+
loadStream: base_loadStream,
|
|
68
|
+
save: base_save,
|
|
69
|
+
saveBatch: base_saveBatch,
|
|
70
|
+
count: base_count,
|
|
71
|
+
delete: base_delete,
|
|
72
|
+
deleteBatch: base_deleteBatch
|
|
73
|
+
};
|
|
74
|
+
return [
|
|
75
|
+
base,
|
|
76
|
+
saved
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function memberOf(json) {
|
|
81
|
+
return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => o["geolocation"]), Stdlib_JSON.Decode.object), o => o["__typename"]), Stdlib_JSON.Decode.string);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function row() {
|
|
85
|
+
return Message$ReventlessCore.encode({
|
|
86
|
+
customerId: "c1",
|
|
87
|
+
geolocation: {
|
|
88
|
+
TAG: "Located",
|
|
89
|
+
lat: 1.0,
|
|
90
|
+
lng: 2.0
|
|
91
|
+
}
|
|
92
|
+
}, rowStateSchema);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
globalThis.describe("ProjectionEntryPoint_Ops.withUnionMemberTypes", () => {
|
|
96
|
+
globalThis.test("stamps the member type on save", async () => {
|
|
97
|
+
let match = capture();
|
|
98
|
+
let ops = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(match[0], rowStateSchema);
|
|
99
|
+
await ops.save("c1", row(), "Any", undefined);
|
|
100
|
+
globalThis.expect(Stdlib_Option.flatMap(match[1][0], memberOf)).toEqual("GeolocationLocated");
|
|
101
|
+
});
|
|
102
|
+
globalThis.test("stamps every row in a batch", async () => {
|
|
103
|
+
let match = capture();
|
|
104
|
+
let ops = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(match[0], rowStateSchema);
|
|
105
|
+
await ops.saveBatch([
|
|
106
|
+
[
|
|
107
|
+
"c1",
|
|
108
|
+
row(),
|
|
109
|
+
undefined
|
|
110
|
+
],
|
|
111
|
+
[
|
|
112
|
+
"c2",
|
|
113
|
+
row(),
|
|
114
|
+
undefined
|
|
115
|
+
]
|
|
116
|
+
]);
|
|
117
|
+
globalThis.expect(match[1].map(memberOf)).toEqual([
|
|
118
|
+
"GeolocationLocated",
|
|
119
|
+
"GeolocationLocated"
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
globalThis.test("passes rows through when the spec has no schema", async () => {
|
|
123
|
+
let match = capture();
|
|
124
|
+
let ops = ProjectionEntryPoint_Ops$ReventlessAws.withUnionMemberTypes(match[0], undefined);
|
|
125
|
+
await ops.save("c1", row(), "Any", undefined);
|
|
126
|
+
globalThis.expect(Stdlib_Option.flatMap(match[1][0], memberOf)).toEqual(undefined);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
geolocationSchema$1 as geolocationSchema,
|
|
132
|
+
rowStateSchema,
|
|
133
|
+
capture,
|
|
134
|
+
memberOf,
|
|
135
|
+
row,
|
|
136
|
+
}
|
|
137
|
+
/* geolocationSchema Not a pure module */
|
|
@@ -85,6 +85,7 @@ let runOneEvent: (string, JSON.t) => promise<unit> = %raw(`
|
|
|
85
85
|
project: projectionModule.project,
|
|
86
86
|
config: specModule.config,
|
|
87
87
|
subIdConfig: specModule.subIdConfig,
|
|
88
|
+
stateSchema: specModule.stateSchema,
|
|
88
89
|
},
|
|
89
90
|
);
|
|
90
91
|
const event = {
|
|
@@ -146,6 +147,21 @@ describe("StateViewSliceEntryPoint integration", () => {
|
|
|
146
147
|
->Array.toSorted(String.compare)
|
|
147
148
|
expect(productIds)->toEqual(["prod-a", "prod-b"])
|
|
148
149
|
|
|
150
|
+
// The runtime path's union stamp, against real DynamoDB. The deployed
|
|
151
|
+
// Lambdas assemble JSON-level ops and skipped it entirely, so a member
|
|
152
|
+
// resolved to null and took its non-nullable parent with it.
|
|
153
|
+
let members =
|
|
154
|
+
rows
|
|
155
|
+
->Array.filterMap(row =>
|
|
156
|
+
row
|
|
157
|
+
->JSON.Decode.object
|
|
158
|
+
->Option.flatMap(o => o->Dict.get("fulfilment"))
|
|
159
|
+
->Option.flatMap(JSON.Decode.object)
|
|
160
|
+
->Option.flatMap(o => o->Dict.get("__typename"))
|
|
161
|
+
->Option.flatMap(JSON.Decode.string)
|
|
162
|
+
)
|
|
163
|
+
expect(members)->toEqual(["FulfilmentShipped", "FulfilmentShipped"])
|
|
164
|
+
|
|
149
165
|
await H.deleteTable(table)
|
|
150
166
|
},
|
|
151
167
|
)
|
|
@@ -94,6 +94,7 @@ let runOneEvent = (async (tableName, eventJson) => {
|
|
|
94
94
|
project: projectionModule.project,
|
|
95
95
|
config: specModule.config,
|
|
96
96
|
subIdConfig: specModule.subIdConfig,
|
|
97
|
+
stateSchema: specModule.stateSchema,
|
|
97
98
|
},
|
|
98
99
|
);
|
|
99
100
|
const event = {
|
|
@@ -138,6 +139,11 @@ globalThis.describe("StateViewSliceEntryPoint integration", () => {
|
|
|
138
139
|
"prod-a",
|
|
139
140
|
"prod-b"
|
|
140
141
|
]);
|
|
142
|
+
let members = Stdlib_Array.filterMap(rows$1, row => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(row), o => o["fulfilment"]), Stdlib_JSON.Decode.object), o => o["__typename"]), Stdlib_JSON.Decode.string));
|
|
143
|
+
globalThis.expect(members).toEqual([
|
|
144
|
+
"FulfilmentShipped",
|
|
145
|
+
"FulfilmentShipped"
|
|
146
|
+
]);
|
|
141
147
|
return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
|
|
142
148
|
});
|
|
143
149
|
});
|
|
@@ -16,11 +16,21 @@
|
|
|
16
16
|
@schema
|
|
17
17
|
type consumedEvent = ItemAddedToCart({cartId: string, productId: string, qty: int})
|
|
18
18
|
|
|
19
|
+
// A union on the state, so the integration test covers the runtime path's
|
|
20
|
+
// `__typename` stamp — the one the deployed Lambdas skipped entirely.
|
|
21
|
+
@schema
|
|
22
|
+
type fulfilment =
|
|
23
|
+
| Pending({requestedFor: string})
|
|
24
|
+
| Shipped({carrier: string})
|
|
25
|
+
|
|
26
|
+
let fulfilmentSchema = Reventless.TaggedUnion.named(~name="Fulfilment", fulfilmentSchema)
|
|
27
|
+
|
|
19
28
|
@schema
|
|
20
29
|
type state = {
|
|
21
30
|
cartId: string,
|
|
22
31
|
productId: string,
|
|
23
32
|
qty: int,
|
|
33
|
+
fulfilment: fulfilment,
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
// `Some({subIdField, getSubId})` — the compiled per-slice spec that a real
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Sury from "sury";
|
|
4
|
+
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
4
5
|
|
|
5
6
|
let consumedEventSchema = Sury.$schema(s => ({
|
|
6
7
|
TAG: "ItemAddedToCart",
|
|
@@ -9,10 +10,24 @@ let consumedEventSchema = Sury.$schema(s => ({
|
|
|
9
10
|
qty: s.m(Sury.int)
|
|
10
11
|
}));
|
|
11
12
|
|
|
13
|
+
let fulfilmentSchema = Sury.union([
|
|
14
|
+
Sury.$schema(s => ({
|
|
15
|
+
TAG: "Pending",
|
|
16
|
+
requestedFor: s.m(Sury.string)
|
|
17
|
+
})),
|
|
18
|
+
Sury.$schema(s => ({
|
|
19
|
+
TAG: "Shipped",
|
|
20
|
+
carrier: s.m(Sury.string)
|
|
21
|
+
}))
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
let fulfilmentSchema$1 = TaggedUnion$Reventless.named("Fulfilment", fulfilmentSchema);
|
|
25
|
+
|
|
12
26
|
let stateSchema = Sury.$schema(s => ({
|
|
13
27
|
cartId: s.m(Sury.string),
|
|
14
28
|
productId: s.m(Sury.string),
|
|
15
|
-
qty: s.m(Sury.int)
|
|
29
|
+
qty: s.m(Sury.int),
|
|
30
|
+
fulfilment: s.m(fulfilmentSchema$1)
|
|
16
31
|
}));
|
|
17
32
|
|
|
18
33
|
let subIdConfig = {
|
|
@@ -22,6 +37,7 @@ let subIdConfig = {
|
|
|
22
37
|
|
|
23
38
|
export {
|
|
24
39
|
consumedEventSchema,
|
|
40
|
+
fulfilmentSchema$1 as fulfilmentSchema,
|
|
25
41
|
stateSchema,
|
|
26
42
|
subIdConfig,
|
|
27
43
|
}
|
|
@@ -17,7 +17,9 @@ let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
|
|
|
17
17
|
cartId,
|
|
18
18
|
states => {
|
|
19
19
|
let others = states->Array.filter(s => s.productId != productId)
|
|
20
|
-
others->Array.concat([
|
|
20
|
+
others->Array.concat([
|
|
21
|
+
{cartId, productId, qty, fulfilment: Shipped({carrier: "dhl"})},
|
|
22
|
+
])
|
|
21
23
|
},
|
|
22
24
|
),
|
|
23
25
|
]
|