@reventlessdev/reventless-aws 3.0.0-alpha.241 → 3.0.0-alpha.243

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,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.243 (2026-07-29)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** call the AppSync operations that exist, not the resource's names ([777c8ff](https://github.com/ReventlessDev/reventless-core/commit/777c8ff7e3641b4e7d2228b332f62c14fe690a42))
11
+
12
+
13
+ # 3.0.0-alpha.242 (2026-07-29)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **platform:** export the bucket's physical name, not the layout's ([43b8ba4](https://github.com/ReventlessDev/reventless-core/commit/43b8ba4f04499c918c7c16921962ad640ea20198))
18
+ ### Features
19
+
20
+ * **platform:** fail a plugin deploy that needs a store the platform lacks ([8f2b6e6](https://github.com/ReventlessDev/reventless-core/commit/8f2b6e6ca8ea472c70bee40e57c959a64f2a7b4e))
21
+
22
+
6
23
  # 3.0.0-alpha.241 (2026-07-29)
7
24
 
8
25
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.241",
3
+ "version": "3.0.0-alpha.243",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -13,15 +13,15 @@
13
13
  "uuid": "^13.0.0",
14
14
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.25",
15
15
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
16
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.61",
17
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
18
16
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
17
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.61",
19
19
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
20
- "@reventlessdev/reventless-core": "3.0.0-alpha.190",
21
- "@reventlessdev/reventless-infra": "3.0.0-alpha.108",
20
+ "@reventlessdev/reventless-core": "3.0.0-alpha.191",
21
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.109",
22
22
  "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
23
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.54",
24
- "@reventlessdev/reventless-spec": "3.0.0-alpha.83"
23
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.55",
24
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.84"
25
25
  },
26
26
  "devDependencies": {
27
27
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -61,7 +61,12 @@ type objectStoreEndpoint = {
61
61
  // The path this store's keys are rooted at, and therefore the path it must be
62
62
  // served under for a minted `/{key}` ref to resolve.
63
63
  keyPrefix: string,
64
- bucketName: string,
64
+ // The bucket's **physical** name, which is not the name the layout computes:
65
+ // Pulumi auto-names the resource, so a `SharedBucket` stack asking for
66
+ // `alpha-stores` gets `alpha-stores-507202d`. An Output because that suffix is
67
+ // only known after the bucket resolves — and a plain string here is exactly
68
+ // how this shipped exporting a bucket name that no ARN matches.
69
+ bucketName: Pulumi.Output.t<string>,
65
70
  uploadUrl: Pulumi.Output.t<string>,
66
71
  // Public base URL the store is served from, when the platform serves it
67
72
  // itself. `None` when a host-UI bundle is deployed — there the shell's own
@@ -1586,7 +1591,11 @@ module MakeWithConfig = (
1586
1591
  ~bucketName=storeHandle.bucketName,
1587
1592
  ~servedPrefix=storeHandle.keyPrefix,
1588
1593
  )
1589
- (`${plugin}.${store}`, keyPrefix, bucketName, presign.url)
1594
+ // `bucketName` is the layout's *logical* name and groups the served view
1595
+ // below; `storeHandle.bucketName` is the physical one Pulumi resolves, and
1596
+ // is what a consumer needs to build an ARN. Carrying both is the point —
1597
+ // they are different strings and only one of them exists in S3.
1598
+ (`${plugin}.${store}`, keyPrefix, bucketName, storeHandle.bucketName, presign.url)
1590
1599
  })
1591
1600
 
1592
1601
  // Group the served view by bucket: one origin and one bucket policy per
@@ -1597,7 +1606,7 @@ module MakeWithConfig = (
1597
1606
  ->Option.map(b => {
1598
1607
  ReventlessInfra.Platform.id: bucketName,
1599
1608
  prefixes: declaredStoreServices
1600
- ->Array.filterMap(((_, prefix, bn, _)) => bn == bucketName ? Some(prefix) : None),
1609
+ ->Array.filterMap(((_, prefix, bn, _, _)) => bn == bucketName ? Some(prefix) : None),
1601
1610
  bucketId: b.bucketId,
1602
1611
  bucketArn: b.bucketArn,
1603
1612
  bucketRegionalDomainName: b.bucketRegionalDomainName,
@@ -1634,9 +1643,16 @@ module MakeWithConfig = (
1634
1643
  let declaredStoreEndpoints = declaredStoreServices->Array.map(((
1635
1644
  store,
1636
1645
  keyPrefix,
1637
- bucketName,
1646
+ _logicalBucketName,
1647
+ physicalBucketName,
1638
1648
  uploadUrl,
1639
- )) => {store, keyPrefix, bucketName, uploadUrl, baseUrl: storeServingBaseUrl})
1649
+ )) => {
1650
+ store,
1651
+ keyPrefix,
1652
+ bucketName: physicalBucketName->Pulumi.Output.fromInput,
1653
+ uploadUrl,
1654
+ baseUrl: storeServingBaseUrl,
1655
+ })
1640
1656
  objectStoreEndpointsRef := declaredStoreEndpoints
1641
1657
 
1642
1658
  // Exported unconditionally — the provisioning above already is. Their only
@@ -1657,18 +1673,18 @@ module MakeWithConfig = (
1657
1673
  "objectStores",
1658
1674
  declaredStoreEndpoints
1659
1675
  ->Array.map(e =>
1660
- switch e.baseUrl {
1661
- | Some(u) => u->Pulumi.Output.apply(b => (e, Some(b)))
1662
- | None => Pulumi.Output.make((e, None))
1663
- }
1676
+ Pulumi.Output.all2((e.bucketName, e.baseUrl->Pulumi.Output.allOpt))->Pulumi.Output.apply(((
1677
+ bucketName,
1678
+ baseUrl,
1679
+ )) => (e, bucketName, baseUrl))
1664
1680
  )
1665
1681
  ->Pulumi.Output.all
1666
- ->Pulumi.Output.apply(pairs =>
1667
- pairs
1668
- ->Array.map(((e, baseUrl)) => (
1682
+ ->Pulumi.Output.apply(resolved =>
1683
+ resolved
1684
+ ->Array.map(((e, bucketName, baseUrl)) => (
1669
1685
  e.store,
1670
1686
  [
1671
- ("bucketName", JSON.Encode.string(e.bucketName)),
1687
+ ("bucketName", JSON.Encode.string(bucketName)),
1672
1688
  ("keyPrefix", JSON.Encode.string(e.keyPrefix)),
1673
1689
  ]
1674
1690
  // Absent when the host shell serves the store: there the object is
@@ -1825,7 +1841,7 @@ module MakeWithConfig = (
1825
1841
  // built before per-store binding reads it and is unaffected.
1826
1842
  let storeUploadEndpointsOutput: Pulumi.Output.t<array<(string, string)>> =
1827
1843
  declaredStoreServices
1828
- ->Array.map(((qualified, _, _, url)) => url->Pulumi.Output.apply(u => (qualified, u)))
1844
+ ->Array.map(((qualified, _, _, _, url)) => url->Pulumi.Output.apply(u => (qualified, u)))
1829
1845
  ->Pulumi.Output.all
1830
1846
 
1831
1847
  let configJsonContent =
@@ -2053,11 +2069,66 @@ module MakeWithConfig = (
2053
2069
  ~mergedApiIdentifier=checkedMergedApiArn,
2054
2070
  ~association,
2055
2071
  )
2072
+
2073
+ // ── Capability coverage ────────────────────────────────────────────────
2074
+ //
2075
+ // `requiredStores` is what this plugin's fields *declare*; the platform's
2076
+ // `objectStores` output is what it actually provisioned. A store in the
2077
+ // first and not the second is the split-stack ordering hazard: the
2078
+ // platform deploys before the plugin and cannot see the plugin's schemas,
2079
+ // so the capability list is hand-written and can simply be wrong.
2080
+ //
2081
+ // It is worth failing on because every symptom is silent. The upload
2082
+ // input finds no per-store endpoint, falls back to the legacy single
2083
+ // service, and writes to whatever bucket that serves — a 2xx, a plausible
2084
+ // ref, and the wrong destination. A case slip in the hand-written name
2085
+ // produces exactly that, and so does forgetting the entry entirely.
2086
+ //
2087
+ // Two outcomes rather than one, because "you got it wrong" and "you have
2088
+ // not started" deserve different answers. A platform exporting no stores
2089
+ // at all has not adopted capability provisioning; failing it would break
2090
+ // deployments that are working. A platform exporting *some* stores but
2091
+ // not this one has adopted it and is missing or misspelling an entry.
2092
+ //
2093
+ // Folded into the association export for the same reason the merge gate
2094
+ // is: a dangling `apply` is not guaranteed to be evaluated, and a check
2095
+ // that might not run is not a check.
2096
+ let capabilityGate: Pulumi.Output.t<unit> =
2097
+ (pluginOutputs.pluginStructure, stackRef->Pulumi.StackReference.getOutput("objectStores"))
2098
+ ->Pulumi.Output.all2
2099
+ ->Pulumi.Output.apply((((structure, objectStores): (_, option<JSON.t>))) => {
2100
+ let required =
2101
+ structure->Option.flatMap(s => s.requiredStores)->Option.getOr([])
2102
+ let provisioned =
2103
+ objectStores
2104
+ ->Option.flatMap(JSON.Decode.object)
2105
+ ->Option.map(Dict.keysToArray)
2106
+ ->Option.getOr([])
2107
+ switch Util_StoreLayout.coverageFor(~required, ~provisioned) {
2108
+ | Covered => ()
2109
+ | NotAdopted(missing) =>
2110
+ log.warn(
2111
+ ~comp="Platform:deployPlugin",
2112
+ `declares ${missing->Array.join(", ")} but the platform stack provisions no object stores — ` ++
2113
+ `add them to the platform's ~capabilities and redeploy the platform first, ` ++
2114
+ `or uploads will fall back to the legacy service and write to the wrong bucket`,
2115
+ )
2116
+ | Missing({missing, provisioned}) =>
2117
+ JsError.throwWithMessage(
2118
+ `Plugin requires object store(s) the platform does not provision: ${missing->Array.join(", ")}.\n` ++
2119
+ ` The platform stack provisions: ${provisioned->Array.join(", ")}.\n` ++
2120
+ ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — ` ++
2121
+ `check the capability's spelling and case against it.\n` ++
2122
+ ` Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`,
2123
+ )
2124
+ }
2125
+ })
2126
+
2056
2127
  Pulumi.Pulumi.export(
2057
2128
  "sourceApiAssociationId",
2058
- (association.associationId, mergeGate)
2059
- ->Pulumi.Output.all2
2060
- ->Pulumi.Output.apply(((id, _)) => id),
2129
+ (association.associationId, mergeGate, capabilityGate)
2130
+ ->Pulumi.Output.all3
2131
+ ->Pulumi.Output.apply(((id, _, _)) => id),
2061
2132
  )
2062
2133
  Pulumi.Pulumi.export(
2063
2134
  "pluginSourceApiId",
@@ -11,6 +11,7 @@ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output
11
11
  import * as Pulumi$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Pulumi.res.mjs";
12
12
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
13
13
  import * as Pulumi from "@pulumi/pulumi";
14
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
14
15
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
15
16
  import * as Plugin$ReventlessAws from "./components/Plugin.res.mjs";
16
17
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
@@ -935,6 +936,7 @@ function MakeWithConfig(Config) {
935
936
  plugin + `.` + store,
936
937
  keyPrefix,
937
938
  bucketName,
939
+ storeHandle.bucketName,
938
940
  presign.url
939
941
  ];
940
942
  });
@@ -963,8 +965,8 @@ function MakeWithConfig(Config) {
963
965
  let declaredStoreEndpoints = declaredStoreServices.map(param => ({
964
966
  store: param[0],
965
967
  keyPrefix: param[1],
966
- bucketName: param[2],
967
- uploadUrl: param[3],
968
+ bucketName: param[3],
969
+ uploadUrl: param[4],
968
970
  baseUrl: storeServingBaseUrl
969
971
  }));
970
972
  objectStoreEndpointsRef.contents = declaredStoreEndpoints;
@@ -973,28 +975,22 @@ function MakeWithConfig(Config) {
973
975
  e.store,
974
976
  u
975
977
  ]))).apply(pairs => Object.fromEntries(pairs)));
976
- Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => {
977
- let u = e.baseUrl;
978
- if (u !== undefined) {
979
- return u.apply(b => [
980
- e,
981
- b
982
- ]);
983
- } else {
984
- return Pulumi.output([
985
- e,
986
- undefined
987
- ]);
988
- }
989
- })).apply(pairs => Object.fromEntries(pairs.map(param => {
990
- let baseUrl = param[1];
978
+ Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => Pulumi.all([
979
+ e.bucketName,
980
+ Output$Pulumi.allOpt(e.baseUrl)
981
+ ]).apply(param => [
982
+ e,
983
+ param[0],
984
+ param[1]
985
+ ]))).apply(resolved => Object.fromEntries(resolved.map(param => {
986
+ let baseUrl = param[2];
991
987
  let e = param[0];
992
988
  return [
993
989
  e.store,
994
990
  Object.fromEntries([
995
991
  [
996
992
  "bucketName",
997
- e.bucketName
993
+ param[1]
998
994
  ],
999
995
  [
1000
996
  "keyPrefix",
@@ -1047,7 +1043,7 @@ function MakeWithConfig(Config) {
1047
1043
  let uploadEndpointOutput = store$1 !== undefined ? Upload_Presign_S3$ReventlessAws.make(store$1.bucketName, undefined, undefined, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
1048
1044
  let storeUploadEndpointsOutput = Pulumi.all(declaredStoreServices.map(param => {
1049
1045
  let qualified = param[0];
1050
- return param[3].apply(u => [
1046
+ return param[4].apply(u => [
1051
1047
  qualified,
1052
1048
  u
1053
1049
  ]);
@@ -1208,9 +1204,25 @@ function MakeWithConfig(Config) {
1208
1204
  ]).apply(param => param[0]);
1209
1205
  let association = AppSync_MergedApi$ReventlessAws.associateSourceWithMergedArn("PluginSourceAssociation", arnAfterSchemaPush, domainApi, {});
1210
1206
  let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
1207
+ let capabilityGate = Pulumi.all([
1208
+ pluginOutputs.pluginStructure,
1209
+ stackRef.getOutput("objectStores")
1210
+ ]).apply(param => {
1211
+ let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
1212
+ let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
1213
+ let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
1214
+ if (typeof missing !== "object") {
1215
+ return;
1216
+ } else if (missing.TAG === "NotAdopted") {
1217
+ return log.warn("Platform:deployPlugin", undefined, `declares ` + missing._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
1218
+ } else {
1219
+ return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
1220
+ }
1221
+ });
1211
1222
  Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
1212
1223
  association.associationId,
1213
- mergeGate
1224
+ mergeGate,
1225
+ capabilityGate
1214
1226
  ]).apply(param => param[0]));
1215
1227
  Pulumi$Pulumi.$$export("pluginSourceApiId", Output$Pulumi.flatMap(domainApi, api => api.id));
1216
1228
  Pulumi$Pulumi.$$export("pluginSourceApiEndpoint", Output$Pulumi.flatMap(domainApi, api => api.uris.apply(uris => uris.GRAPHQL)));
@@ -2079,6 +2091,7 @@ function Make($star) {
2079
2091
  plugin + `.` + store,
2080
2092
  keyPrefix,
2081
2093
  bucketName,
2094
+ storeHandle.bucketName,
2082
2095
  presign.url
2083
2096
  ];
2084
2097
  });
@@ -2107,8 +2120,8 @@ function Make($star) {
2107
2120
  let declaredStoreEndpoints = declaredStoreServices.map(param => ({
2108
2121
  store: param[0],
2109
2122
  keyPrefix: param[1],
2110
- bucketName: param[2],
2111
- uploadUrl: param[3],
2123
+ bucketName: param[3],
2124
+ uploadUrl: param[4],
2112
2125
  baseUrl: storeServingBaseUrl
2113
2126
  }));
2114
2127
  objectStoreEndpointsRef.contents = declaredStoreEndpoints;
@@ -2117,28 +2130,22 @@ function Make($star) {
2117
2130
  e.store,
2118
2131
  u
2119
2132
  ]))).apply(pairs => Object.fromEntries(pairs)));
2120
- Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => {
2121
- let u = e.baseUrl;
2122
- if (u !== undefined) {
2123
- return u.apply(b => [
2124
- e,
2125
- b
2126
- ]);
2127
- } else {
2128
- return Pulumi.output([
2129
- e,
2130
- undefined
2131
- ]);
2132
- }
2133
- })).apply(pairs => Object.fromEntries(pairs.map(param => {
2134
- let baseUrl = param[1];
2133
+ Pulumi$Pulumi.$$export("objectStores", Pulumi.all(declaredStoreEndpoints.map(e => Pulumi.all([
2134
+ e.bucketName,
2135
+ Output$Pulumi.allOpt(e.baseUrl)
2136
+ ]).apply(param => [
2137
+ e,
2138
+ param[0],
2139
+ param[1]
2140
+ ]))).apply(resolved => Object.fromEntries(resolved.map(param => {
2141
+ let baseUrl = param[2];
2135
2142
  let e = param[0];
2136
2143
  return [
2137
2144
  e.store,
2138
2145
  Object.fromEntries([
2139
2146
  [
2140
2147
  "bucketName",
2141
- e.bucketName
2148
+ param[1]
2142
2149
  ],
2143
2150
  [
2144
2151
  "keyPrefix",
@@ -2191,7 +2198,7 @@ function Make($star) {
2191
2198
  let uploadEndpointOutput = store$1 !== undefined ? Upload_Presign_S3$ReventlessAws.make(store$1.bucketName, undefined, undefined, undefined, undefined).url.apply(u => u) : Pulumi.output(undefined);
2192
2199
  let storeUploadEndpointsOutput = Pulumi.all(declaredStoreServices.map(param => {
2193
2200
  let qualified = param[0];
2194
- return param[3].apply(u => [
2201
+ return param[4].apply(u => [
2195
2202
  qualified,
2196
2203
  u
2197
2204
  ]);
@@ -2352,9 +2359,25 @@ function Make($star) {
2352
2359
  ]).apply(param => param[0]);
2353
2360
  let association = AppSync_MergedApi$ReventlessAws.associateSourceWithMergedArn("PluginSourceAssociation", arnAfterSchemaPush, domainApi, {});
2354
2361
  let mergeGate = AppSync_MergedApi$ReventlessAws.mergeStatusGateWith(checkedMergedApiArn, association);
2362
+ let capabilityGate = Pulumi.all([
2363
+ pluginOutputs.pluginStructure,
2364
+ stackRef.getOutput("objectStores")
2365
+ ]).apply(param => {
2366
+ let required = Stdlib_Option.getOr(Stdlib_Option.flatMap(param[0], s => s.requiredStores), []);
2367
+ let provisioned = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(param[1], Stdlib_JSON.Decode.object), prim => Object.keys(prim)), []);
2368
+ let missing = Util_StoreLayout$ReventlessAws.coverageFor(required, provisioned);
2369
+ if (typeof missing !== "object") {
2370
+ return;
2371
+ } else if (missing.TAG === "NotAdopted") {
2372
+ return log.warn("Platform:deployPlugin", undefined, `declares ` + missing._0.join(", ") + ` but the platform stack provisions no object stores — add them to the platform's ~capabilities and redeploy the platform first, or uploads will fall back to the legacy service and write to the wrong bucket`);
2373
+ } else {
2374
+ return Stdlib_JsError.throwWithMessage(`Plugin requires object store(s) the platform does not provision: ` + missing.missing.join(", ") + `.\n` + (` The platform stack provisions: ` + missing.provisioned.join(", ") + `.\n`) + ` A store's key is {plugin}.{store}, where {plugin} is the name the plugin registers — check the capability's spelling and case against it.\n Add the missing entr(ies) to the platform's ~capabilities and redeploy the platform stack first.`);
2375
+ }
2376
+ });
2355
2377
  Pulumi$Pulumi.$$export("sourceApiAssociationId", Pulumi.all([
2356
2378
  association.associationId,
2357
- mergeGate
2379
+ mergeGate,
2380
+ capabilityGate
2358
2381
  ]).apply(param => param[0]));
2359
2382
  Pulumi$Pulumi.$$export("pluginSourceApiId", Output$Pulumi.flatMap(domainApi, api => api.id));
2360
2383
  Pulumi$Pulumi.$$export("pluginSourceApiEndpoint", Output$Pulumi.flatMap(domainApi, api => api.uris.apply(uris => uris.GRAPHQL)));
@@ -63,11 +63,21 @@ type deleteCmd
63
63
 
64
64
  // Shape of the dynamically-imported `@aws-sdk/client-appsync` module. Only the
65
65
  // three association commands are needed here.
66
+ //
67
+ // The create/delete names follow the *API operation*, not the resource: AppSync
68
+ // has `AssociateSourceGraphqlApi` / `DisassociateSourceGraphqlApi`, and there is
69
+ // no `Create…`/`Delete…SourceApiAssociation` command. Those names are only the
70
+ // CloudFormation/Pulumi resource's, and naming the constructors after the
71
+ // resource made `create` throw `C is not a constructor` on every fresh stack —
72
+ // `new undefined(...)`, since a missing export is just undefined on the module
73
+ // namespace. `Get` exists under the resource-style name, which is why read and
74
+ // diff always worked and only creation was broken. The request payloads below
75
+ // already match the operations exactly.
66
76
  type sdkModule = {
67
77
  @as("AppSyncClient") clientCtor: unit => appSyncClient,
68
- @as("CreateSourceApiAssociationCommand") createCtor: createInput => createCmd,
78
+ @as("AssociateSourceGraphqlApiCommand") createCtor: createInput => createCmd,
69
79
  @as("GetSourceApiAssociationCommand") getCtor: idInput => getCmd,
70
- @as("DeleteSourceApiAssociationCommand") deleteCtor: idInput => deleteCmd,
80
+ @as("DisassociateSourceGraphqlApiCommand") deleteCtor: idInput => deleteCmd,
71
81
  }
72
82
 
73
83
  // Calls a constructor with `new` — the SDK exports plain classes.
@@ -277,7 +287,7 @@ let extractIds = (resp: createResult): (string, string) =>
277
287
  | Some({associationArn: ?Some(arn), associationId: ?Some(aid)}) => (arn, aid)
278
288
  | _ =>
279
289
  JsError.throwWithMessage(
280
- "CreateSourceApiAssociation returned no associationArn / associationId",
290
+ "AssociateSourceGraphqlApi returned no associationArn / associationId",
281
291
  )
282
292
  }
283
293
 
@@ -181,11 +181,11 @@ function identifiersFrom(id, props) {
181
181
  function extractIds(resp) {
182
182
  let match = resp.sourceApiAssociation;
183
183
  if (match === undefined) {
184
- return Stdlib_JsError.throwWithMessage("CreateSourceApiAssociation returned no associationArn / associationId");
184
+ return Stdlib_JsError.throwWithMessage("AssociateSourceGraphqlApi returned no associationArn / associationId");
185
185
  }
186
186
  let aid = match.associationId;
187
187
  if (aid === undefined) {
188
- return Stdlib_JsError.throwWithMessage("CreateSourceApiAssociation returned no associationArn / associationId");
188
+ return Stdlib_JsError.throwWithMessage("AssociateSourceGraphqlApi returned no associationArn / associationId");
189
189
  }
190
190
  let arn = match.associationArn;
191
191
  if (arn !== undefined) {
@@ -194,14 +194,14 @@ function extractIds(resp) {
194
194
  aid
195
195
  ];
196
196
  } else {
197
- return Stdlib_JsError.throwWithMessage("CreateSourceApiAssociation returned no associationArn / associationId");
197
+ return Stdlib_JsError.throwWithMessage("AssociateSourceGraphqlApi returned no associationArn / associationId");
198
198
  }
199
199
  }
200
200
 
201
201
  async function create(inputs) {
202
202
  let sdk = await getSdk();
203
203
  let client = await getClient();
204
- let match = await runWithRetry(undefined, undefined, undefined, undefined, () => client.send(newOf1(sdk.CreateSourceApiAssociationCommand, {
204
+ let match = await runWithRetry(undefined, undefined, undefined, undefined, () => client.send(newOf1(sdk.AssociateSourceGraphqlApiCommand, {
205
205
  mergedApiIdentifier: inputs.mergedApiIdentifier,
206
206
  sourceApiIdentifier: inputs.sourceApiIdentifier,
207
207
  sourceApiAssociationConfig: {
@@ -235,7 +235,7 @@ async function delete_(id, props) {
235
235
  return log.warn("AppSync_SourceApiAssociation_Retrying", undefined, `delete: could not derive association identifiers from id "` + id + `"; treating as already gone`);
236
236
  }
237
237
  try {
238
- return await runWithRetry(undefined, undefined, undefined, undefined, () => client.send(newOf1(sdk.DeleteSourceApiAssociationCommand, ids)));
238
+ return await runWithRetry(undefined, undefined, undefined, undefined, () => client.send(newOf1(sdk.DisassociateSourceGraphqlApiCommand, ids)));
239
239
  } catch (raw_exn) {
240
240
  let exn = Primitive_exceptions.internalToException(raw_exn);
241
241
  if (Stdlib_Option.mapOr(Stdlib_JsExn.fromException(exn), false, isAlreadyGoneError)) {
@@ -34,6 +34,17 @@ type protection =
34
34
  | Protected
35
35
  | Unprotected
36
36
 
37
+ /** Whether the platform provisions everything a plugin's fields declare. */
38
+ type coverage =
39
+ /** Every declared store is provisioned. */
40
+ | Covered
41
+ /** The platform provisions no stores at all — it has not adopted capability
42
+ provisioning, which is a different situation from getting it wrong. */
43
+ | NotAdopted(array<string>)
44
+ /** The platform provisions stores, but not these. Carries what it does
45
+ provision, because the usual cause is a near-miss worth showing. */
46
+ | Missing({missing: array<string>, provisioned: array<string>})
47
+
37
48
  /** Which distribution fronts the declared stores. */
38
49
  type serving =
39
50
  /** No store is declared, so nothing is served. */
@@ -134,3 +145,31 @@ let servingFor = (~hasHostUiBundle: bool, ~declaredBucketCount: int): serving =>
134
145
  | (true, _) => HostShell
135
146
  | (false, _) => PlatformOwned
136
147
  }
148
+
149
+ /**
150
+ Does the platform provision what the plugin's fields declare?
151
+
152
+ The split-stack ordering hazard, stated as a set difference: the platform
153
+ deploys before the plugin and cannot read its schemas, so its capability list is
154
+ written by hand and can simply be wrong. `required` comes from
155
+ `pluginStructure.requiredStores`; `provisioned` from the platform's exported
156
+ `objectStores` keys. Both are qualified `{plugin}.{store}`.
157
+
158
+ **Three outcomes, not two.** A platform provisioning nothing has not adopted
159
+ capability provisioning; failing it would break deployments that work today. A
160
+ platform provisioning *some* stores but not this one has adopted it and has a
161
+ missing or misspelled entry. Collapsing those two into one verdict forces a
162
+ choice between breaking the first group and not helping the second.
163
+
164
+ Worth being strict about because every symptom is silent: the upload input finds
165
+ no per-store endpoint, falls back to the legacy single service, and writes to
166
+ whatever bucket that serves — a 2xx, a plausible ref, and the wrong destination.
167
+ */
168
+ let coverageFor = (~required: array<string>, ~provisioned: array<string>): coverage => {
169
+ let missing = required->Array.filter(r => !(provisioned->Array.includes(r)))
170
+ switch (missing, provisioned) {
171
+ | ([], _) => Covered
172
+ | (missing, []) => NotAdopted(missing)
173
+ | (missing, provisioned) => Missing({missing, provisioned})
174
+ }
175
+ }
@@ -44,6 +44,26 @@ function servingFor(hasHostUiBundle, declaredBucketCount) {
44
44
  }
45
45
  }
46
46
 
47
+ function coverageFor(required, provisioned) {
48
+ let missing = required.filter(r => !provisioned.includes(r));
49
+ if (missing.length !== 0) {
50
+ if (provisioned.length !== 0) {
51
+ return {
52
+ TAG: "Missing",
53
+ missing: missing,
54
+ provisioned: provisioned
55
+ };
56
+ } else {
57
+ return {
58
+ TAG: "NotAdopted",
59
+ _0: missing
60
+ };
61
+ }
62
+ } else {
63
+ return "Covered";
64
+ }
65
+ }
66
+
47
67
  export {
48
68
  defaultEphemeralPrefixes,
49
69
  layoutFor,
@@ -51,5 +71,6 @@ export {
51
71
  bucketNameFor,
52
72
  keyPrefixFor,
53
73
  servingFor,
74
+ coverageFor,
54
75
  }
55
76
  /* No side effect */
@@ -80,6 +80,80 @@ describe("Util_StoreLayout.servingFor", () => {
80
80
  )
81
81
  })
82
82
 
83
+ describe("Util_StoreLayout.coverageFor", () => {
84
+ testSync("everything declared is provisioned", () =>
85
+ expect(
86
+ Util_StoreLayout.coverageFor(
87
+ ~required=["Catalog.productImages"],
88
+ ~provisioned=["Catalog.productImages"],
89
+ ),
90
+ )->toEqual(Util_StoreLayout.Covered)
91
+ )
92
+
93
+ testSync("declaring nothing is covered, whatever the platform provisions", () =>
94
+ expect(Util_StoreLayout.coverageFor(~required=[], ~provisioned=["Catalog.productImages"]))->toEqual(
95
+ Util_StoreLayout.Covered,
96
+ )
97
+ )
98
+
99
+ // A platform provisioning nothing has not adopted capability provisioning.
100
+ // Failing it would break deployments that work today, so this is the arm that
101
+ // must NOT be a hard error.
102
+ testSync("a platform provisioning nothing has not adopted, rather than got it wrong", () =>
103
+ expect(Util_StoreLayout.coverageFor(~required=["Catalog.productImages"], ~provisioned=[]))->toEqual(
104
+ Util_StoreLayout.NotAdopted(["Catalog.productImages"]),
105
+ )
106
+ )
107
+
108
+ // The case that shipped: the platform declared the store under a lowercased
109
+ // plugin name, so both sides had a productImages store and neither matched.
110
+ // It carries what IS provisioned because the cause is usually a near-miss.
111
+ testSync("a near-miss reports both sides — this is the case-slip shape", () =>
112
+ expect(
113
+ Util_StoreLayout.coverageFor(
114
+ ~required=["Catalog.productImages"],
115
+ ~provisioned=["catalog.productImages"],
116
+ ),
117
+ )->toEqual(
118
+ Util_StoreLayout.Missing({
119
+ missing: ["Catalog.productImages"],
120
+ provisioned: ["catalog.productImages"],
121
+ }),
122
+ )
123
+ )
124
+
125
+ testSync("only the uncovered stores are reported missing", () =>
126
+ expect(
127
+ Util_StoreLayout.coverageFor(
128
+ ~required=["Catalog.productImages", "Catalog.manuals"],
129
+ ~provisioned=["Catalog.productImages"],
130
+ ),
131
+ )->toEqual(
132
+ Util_StoreLayout.Missing({
133
+ missing: ["Catalog.manuals"],
134
+ provisioned: ["Catalog.productImages"],
135
+ }),
136
+ )
137
+ )
138
+
139
+ // Matching is exact. Suffix or case-insensitive matching would "fix" the
140
+ // case slip above by silently binding to the wrong store, and two plugins may
141
+ // legitimately name a store the same.
142
+ testSync("matching is exact — a suffix match is not a match", () =>
143
+ expect(
144
+ Util_StoreLayout.coverageFor(
145
+ ~required=["Catalog.productImages"],
146
+ ~provisioned=["Ordering.productImages"],
147
+ ),
148
+ )->toEqual(
149
+ Util_StoreLayout.Missing({
150
+ missing: ["Catalog.productImages"],
151
+ provisioned: ["Ordering.productImages"],
152
+ }),
153
+ )
154
+ )
155
+ })
156
+
83
157
  describe("Util_StoreLayout.protectionFor", () => {
84
158
  // The pairing a single layout-driven switch would have got wrong: alpha
85
159
  // shares a bucket and is still protected.
@@ -45,6 +45,45 @@ globalThis.describe("Util_StoreLayout.servingFor", () => {
45
45
  });
46
46
  });
47
47
 
48
+ globalThis.describe("Util_StoreLayout.coverageFor", () => {
49
+ globalThis.test("everything declared is provisioned", () => {
50
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["Catalog.productImages"])).toEqual("Covered");
51
+ });
52
+ globalThis.test("declaring nothing is covered, whatever the platform provisions", () => {
53
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor([], ["Catalog.productImages"])).toEqual("Covered");
54
+ });
55
+ globalThis.test("a platform provisioning nothing has not adopted, rather than got it wrong", () => {
56
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], [])).toEqual({
57
+ TAG: "NotAdopted",
58
+ _0: ["Catalog.productImages"]
59
+ });
60
+ });
61
+ globalThis.test("a near-miss reports both sides — this is the case-slip shape", () => {
62
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["catalog.productImages"])).toEqual({
63
+ TAG: "Missing",
64
+ missing: ["Catalog.productImages"],
65
+ provisioned: ["catalog.productImages"]
66
+ });
67
+ });
68
+ globalThis.test("only the uncovered stores are reported missing", () => {
69
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor([
70
+ "Catalog.productImages",
71
+ "Catalog.manuals"
72
+ ], ["Catalog.productImages"])).toEqual({
73
+ TAG: "Missing",
74
+ missing: ["Catalog.manuals"],
75
+ provisioned: ["Catalog.productImages"]
76
+ });
77
+ });
78
+ globalThis.test("matching is exact — a suffix match is not a match", () => {
79
+ globalThis.expect(Util_StoreLayout$ReventlessAws.coverageFor(["Catalog.productImages"], ["Ordering.productImages"])).toEqual({
80
+ TAG: "Missing",
81
+ missing: ["Catalog.productImages"],
82
+ provisioned: ["Ordering.productImages"]
83
+ });
84
+ });
85
+ });
86
+
48
87
  globalThis.describe("Util_StoreLayout.protectionFor", () => {
49
88
  globalThis.test("alpha shares a bucket and is still protected", () => {
50
89
  globalThis.expect(Util_StoreLayout$ReventlessAws.layoutFor("alpha", Util_HostUiDomain$ReventlessAws.defaultProdStacks)).toEqual("SharedBucket");