From 225457646312e44d760b24e91c807ebd306d1235 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Mon, 4 Dec 2023 17:22:21 +0100 Subject: [PATCH] fix(stellate-plugin): Add stellate plugin --- .../action-bar/action-bar-context.md | 2 +- .../action-bar/action-bar-item.md | 13 +- .../action-bar/router-link-definition.md | 2 +- .../nav-menu/add-nav-menu-item.md | 6 +- .../nav-menu/add-nav-menu-section.md | 4 +- .../admin-ui-api/nav-menu/nav-menu-section.md | 13 +- .../register-react-data-table-component.md | 2 +- .../core-plugins/stellate-plugin/index.md | 281 +++++++++++++++++ .../stellate-plugin/purge-rule.md | 95 ++++++ .../stellate-plugin-options.md | 69 +++++ .../stellate-plugin/stellate-service.md | 75 +++++ .../docs/reference/graphql-api/admin/enums.md | 4 +- .../graphql-api/admin/input-types.md | 16 +- .../reference/graphql-api/admin/mutations.md | 4 +- .../graphql-api/admin/object-types.md | 18 +- .../reference/graphql-api/admin/queries.md | 4 +- docs/docs/reference/graphql-api/shop/enums.md | 4 +- .../reference/graphql-api/shop/input-types.md | 85 +++++- .../reference/graphql-api/shop/mutations.md | 4 +- .../graphql-api/shop/object-types.md | 29 +- .../reference/graphql-api/shop/queries.md | 4 +- .../typescript-api/common/permission.md | 2 +- .../errors/error-result-union.md | 34 +++ .../errors/is-graph-ql-error-result.md | 44 +++ .../events/vendure-entity-event.md | 3 +- .../orders/default-guest-checkout-strategy.md | 2 +- .../orders/guest-checkout-strategy.md | 2 +- .../typescript-api/orders/order-process.md | 2 +- .../request/request-context-service.md | 2 +- .../services/channel-service.md | 6 +- .../services/collection-service.md | 2 +- .../services/customer-service.md | 58 ++-- .../typescript-api/services/facet-service.md | 2 +- .../typescript-api/services/order-service.md | 30 +- .../services/product-service.md | 8 +- .../services/promotion-service.md | 4 +- .../typescript-api/services/user-service.md | 2 +- docs/sidebars.js | 6 + package.json | 3 +- packages/stellate-plugin/.gitignore | 3 + packages/stellate-plugin/README.md | 7 + packages/stellate-plugin/index.ts | 6 + packages/stellate-plugin/package.json | 27 ++ .../stellate-plugin/src/api/api-extensions.ts | 17 ++ .../src/api/search-response.resolver.ts | 11 + packages/stellate-plugin/src/constants.ts | 2 + .../src/default-purge-rules.ts | 105 +++++++ packages/stellate-plugin/src/purge-rule.ts | 60 ++++ .../src/service/stellate.service.ts | 150 +++++++++ .../stellate-plugin/src/stellate-plugin.ts | 288 ++++++++++++++++++ packages/stellate-plugin/src/types.ts | 52 ++++ packages/stellate-plugin/src/write.mjs | 210 +++++++++++++ packages/stellate-plugin/tsconfig.build.json | 9 + packages/stellate-plugin/tsconfig.json | 10 + scripts/changelogs/generate-changelog.ts | 1 + scripts/docs/generate-typescript-docs.ts | 4 + 56 files changed, 1804 insertions(+), 104 deletions(-) create mode 100644 docs/docs/reference/core-plugins/stellate-plugin/index.md create mode 100644 docs/docs/reference/core-plugins/stellate-plugin/purge-rule.md create mode 100644 docs/docs/reference/core-plugins/stellate-plugin/stellate-plugin-options.md create mode 100644 docs/docs/reference/core-plugins/stellate-plugin/stellate-service.md create mode 100644 docs/docs/reference/typescript-api/errors/error-result-union.md create mode 100644 docs/docs/reference/typescript-api/errors/is-graph-ql-error-result.md create mode 100644 packages/stellate-plugin/.gitignore create mode 100644 packages/stellate-plugin/README.md create mode 100644 packages/stellate-plugin/index.ts create mode 100644 packages/stellate-plugin/package.json create mode 100644 packages/stellate-plugin/src/api/api-extensions.ts create mode 100644 packages/stellate-plugin/src/api/search-response.resolver.ts create mode 100644 packages/stellate-plugin/src/constants.ts create mode 100644 packages/stellate-plugin/src/default-purge-rules.ts create mode 100644 packages/stellate-plugin/src/purge-rule.ts create mode 100644 packages/stellate-plugin/src/service/stellate.service.ts create mode 100644 packages/stellate-plugin/src/stellate-plugin.ts create mode 100644 packages/stellate-plugin/src/types.ts create mode 100644 packages/stellate-plugin/src/write.mjs create mode 100644 packages/stellate-plugin/tsconfig.build.json create mode 100644 packages/stellate-plugin/tsconfig.json diff --git a/docs/docs/reference/admin-ui-api/action-bar/action-bar-context.md b/docs/docs/reference/admin-ui-api/action-bar/action-bar-context.md index 61c0884fc2..851e11bd73 100644 --- a/docs/docs/reference/admin-ui-api/action-bar/action-bar-context.md +++ b/docs/docs/reference/admin-ui-api/action-bar/action-bar-context.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ActionBarContext - + Providers available to the onClick handler of an ActionBarItem or NavMenuItem. diff --git a/docs/docs/reference/admin-ui-api/action-bar/action-bar-item.md b/docs/docs/reference/admin-ui-api/action-bar/action-bar-item.md index a438a00ae5..f336cc625e 100644 --- a/docs/docs/reference/admin-ui-api/action-bar/action-bar-item.md +++ b/docs/docs/reference/admin-ui-api/action-bar/action-bar-item.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ActionBarItem - + A button in the ActionBar area at the top of one of the list or detail views. @@ -88,7 +88,18 @@ dynamically enable/disable or show/hide the button. +Control the display of this item based on the user permissions. Note: if you attempt to pass a +PermissionDefinition object, you will get a compilation error. Instead, pass the plain +string version. For example, if the permission is defined as: +```ts +export const MyPermission = new PermissionDefinition('ProductReview'); +``` +then the generated permission strings will be: +- `CreateProductReview` +- `ReadProductReview` +- `UpdateProductReview` +- `DeleteProductReview` diff --git a/docs/docs/reference/admin-ui-api/action-bar/router-link-definition.md b/docs/docs/reference/admin-ui-api/action-bar/router-link-definition.md index a74b3d7fae..01f05498be 100644 --- a/docs/docs/reference/admin-ui-api/action-bar/router-link-definition.md +++ b/docs/docs/reference/admin-ui-api/action-bar/router-link-definition.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RouterLinkDefinition - + A function which returns the router link for an ActionBarItem or NavMenuItem. diff --git a/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-item.md b/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-item.md index 1b8dd70e05..2023342de2 100644 --- a/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-item.md +++ b/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-item.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## addNavMenuItem - + Add a menu item to an existing section specified by `sectionId`. The id of the section can be found by inspecting the DOM and finding the `data-section-id` attribute. @@ -24,6 +24,8 @@ This should be used in the NgModule `providers` array of your ui extension modul *Example* ```ts title="providers.ts" +import { addNavMenuItem } from '@vendure/admin-ui/core'; + export default [ addNavMenuItem({ id: 'reviews', @@ -33,7 +35,7 @@ export default [ }, 'marketing'), ]; -`` +``` ```ts title="Signature" function addNavMenuItem(config: NavMenuItem, sectionId: string, before?: string): Provider diff --git a/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-section.md b/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-section.md index 83096dc86a..991d106de6 100644 --- a/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-section.md +++ b/docs/docs/reference/admin-ui-api/nav-menu/add-nav-menu-section.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## addNavMenuSection - + Add a section to the main nav menu. Providing the `before` argument will move the section before any existing section with the specified id. If @@ -22,6 +22,8 @@ This should be used in the NgModule `providers` array of your ui extension modul *Example* ```ts title="providers.ts" +import { addNavMenuSection } from '@vendure/admin-ui/core'; + export default [ addNavMenuSection({ id: 'reports', diff --git a/docs/docs/reference/admin-ui-api/nav-menu/nav-menu-section.md b/docs/docs/reference/admin-ui-api/nav-menu/nav-menu-section.md index 7dec9d293d..b584212335 100644 --- a/docs/docs/reference/admin-ui-api/nav-menu/nav-menu-section.md +++ b/docs/docs/reference/admin-ui-api/nav-menu/nav-menu-section.md @@ -60,7 +60,18 @@ interface NavMenuSection { -Control the display of this item based on the user permissions. +Control the display of this item based on the user permissions. Note: if you attempt to pass a +PermissionDefinition object, you will get a compilation error. Instead, pass the plain +string version. For example, if the permission is defined as: +```ts +export const MyPermission = new PermissionDefinition('ProductReview'); +``` +then the generated permission strings will be: + +- `CreateProductReview` +- `ReadProductReview` +- `UpdateProductReview` +- `DeleteProductReview` ### collapsible diff --git a/docs/docs/reference/admin-ui-api/react-extensions/register-react-data-table-component.md b/docs/docs/reference/admin-ui-api/react-extensions/register-react-data-table-component.md index a948d9de99..60ac5217ed 100644 --- a/docs/docs/reference/admin-ui-api/react-extensions/register-react-data-table-component.md +++ b/docs/docs/reference/admin-ui-api/react-extensions/register-react-data-table-component.md @@ -42,7 +42,7 @@ export default [ tableId: 'product-list', columnId: 'slug', props: { - foo: 'bar', + foo: 'bar', }, }), ]; diff --git a/docs/docs/reference/core-plugins/stellate-plugin/index.md b/docs/docs/reference/core-plugins/stellate-plugin/index.md new file mode 100644 index 0000000000..1908682c18 --- /dev/null +++ b/docs/docs/reference/core-plugins/stellate-plugin/index.md @@ -0,0 +1,281 @@ +--- +title: "StellatePlugin" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## StellatePlugin + + + +A plugin to integrate the [Stellate](https://stellate.co/) GraphQL caching service with your Vendure server. +The main purpose of this plugin is to ensure that cached data gets correctly purged in +response to events inside Vendure. For example, changes to a Product's description should +purge any associated record for that Product in Stellate's cache. + +## Pre-requisites + +You will first need to [set up a free Stellate account](https://stellate.co/signup). + +You will also need to generate an **API token** for the Stellate Purging API. For instructions on how to generate the token, +see the [Stellate Purging API docs](https://docs.stellate.co/docs/purging-api#authentication). + +## Installation + +``` +npm install @vendure/stellate-plugin +``` + +## Configuration + +The plugin is configured via the `StellatePlugin.init()` method. This method accepts an options object +which defines the Stellate service name and API token, as well as an array of PurgeRules which +define how the plugin will respond to Vendure events in order to trigger calls to the +Stellate [Purging API](https://stellate.co/docs/graphql-edge-cache/purging-api). + +*Example* + +```ts +import { StellatePlugin, defaultPurgeRules } from '@vendure/stellate-plugin'; +import { VendureConfig } from '@vendure/core'; + +export const config: VendureConfig = { + // ... + plugins: [ + StellatePlugin.init({ + // The Stellate service name, i.e. `.stellate.sh` + serviceName: 'my-service', + // The API token for the Stellate Purging API. See the "pre-requisites" section above. + apiToken: process.env.STELLATE_PURGE_API_TOKEN, + debugMode: !isProd || process.env.STELLATE_DEBUG_MODE ? true : false, + debugLogging: process.env.STELLATE_DEBUG_MODE ? true : false, + purgeRules: [ + ...defaultPurgeRules, + // custom purge rules can be added here + ], + }), + ], +}; +``` + +In your Stellate dashboard, you can use the following configuration example as a sensible default for a +Vendure application: + +*Example* + +```ts +import { Config } from "stellate"; + +const config: Config = { + config: { + name: "my-vendure-server", + originUrl: "https://my-vendure-server.com/shop-api", + ignoreOriginCacheControl: true, + passThroughOnly: false, + scopes: { + SESSION_BOUND: "header:authorization|cookie:session", + }, + headers: { + "access-control-expose-headers": "vendure-auth-token", + }, + rootTypeNames: { + query: "Query", + mutation: "Mutation", + }, + keyFields: { + types: { + SearchResult: ["productId"], + SearchResponseCacheIdentifier: ["collectionSlug"], + }, + }, + rules: [ + { + types: [ + "Product", + "Collection", + "ProductVariant", + "SearchResponse", + ], + maxAge: 900, + swr: 900, + description: "Cache Products & Collections", + }, + { + types: ["Channel"], + maxAge: 9000, + swr: 9000, + description: "Cache active channel", + }, + { + types: ["Order", "Customer", "User"], + maxAge: 0, + swr: 0, + description: "Do not cache user data", + }, + ], + }, +}; +export default config; +``` + +## Storefront setup + +In your storefront, you should point your GraphQL client to the Stellate GraphQL API endpoint, which is +`https://.stellate.sh`. + +Wherever you are using the `search` query (typically in product listing & search pages), you should also add the +`cacheIdentifier` field to the query. This will ensure that the Stellate cache is correctly purged when +a Product or Collection is updated. + +*Example* + +```ts +import { graphql } from '../generated/gql'; + +export const searchProductsDocument = graphql(` + query SearchProducts($input: SearchInput!) { + search(input: $input) { + // highlight-start + cacheIdentifier { + collectionSlug + } + // highlight-end + items { + # ... + } + } + } +}`); +``` + +## Custom PurgeRules + +The configuration above only accounts for caching of some of the built-in Vendure entity types. If you have +custom entity types, you may well want to add them to the Stellate cache. In this case, you'll also need a way to +purge those entities from the cache when they are updated. This is where the PurgeRule comes in. + +Let's imagine that you have built a simple CMS plugin for Vendure which exposes an `Article` entity in your Shop API, and +you have added this to your Stellate configuration: + +*Example* + +```ts +import { Config } from "stellate"; + +const config: Config = { + config: { + // ... + rules: [ + // ... + { + types: ["Article"], + maxAge: 900, + swr: 900, + description: "Cache Articles", + }, + ], + }, + // ... +}; +export default config; +``` + +You can then add a custom PurgeRule to the StellatePlugin configuration: + +*Example* + +```ts +import { StellatePlugin, defaultPurgeRules } from "@vendure/stellate-plugin"; +import { VendureConfig } from "@vendure/core"; +import { ArticleEvent } from "./plugins/cms/events/article-event"; + +export const config: VendureConfig = { + // ... + plugins: [ + StellatePlugin.init({ + // ... + purgeRules: [ + ...defaultPurgeRules, + new PurgeRule({ + eventType: ArticleEvent, + handler: async ({ events, stellateService }) => { + const articleIds = events.map((e) => e.article.id); + stellateService.purge("Article", articleIds); + }, + }), + ], + }), + ], +}; +``` + +## DevMode & Debug Logging + +In development, you can set `devMode: true`, which will prevent any calls being made to the Stellate Purging API. + +If you want to log the calls that _would_ be made to the Stellate Purge API when in devMode, you can set `debugLogging: true`. +Note that debugLogging generates a lot of debug-level logging, so it is recommended to only enable this when needed. + +*Example* + +```ts +import { StellatePlugin, defaultPurgeRules } from '@vendure/stellate-plugin'; +import { VendureConfig } from '@vendure/core'; + +export const config: VendureConfig = { + // ... + plugins: [ + StellatePlugin.init({ + // ... + devMode: !process.env.PRODUCTION, + debugLogging: process.env.STELLATE_DEBUG_MODE ? true : false, + purgeRules: [ + ...defaultPurgeRules, + ], + }), + ], +}; +``` + +```ts title="Signature" +class StellatePlugin implements OnApplicationBootstrap { + static options: StellatePluginOptions; + init(options: StellatePluginOptions) => ; + constructor(options: StellatePluginOptions, eventBus: EventBus, stellateService: StellateService, moduleRef: ModuleRef) + onApplicationBootstrap() => ; +} +``` +* Implements: OnApplicationBootstrap + + + +
+ +### options + +StellatePluginOptions`} /> + + +### init + +StellatePluginOptions) => `} /> + + +### constructor + +StellatePluginOptions, eventBus: EventBus, stellateService: StellateService, moduleRef: ModuleRef) => StellatePlugin`} /> + + +### onApplicationBootstrap + + `} /> + + + + +
diff --git a/docs/docs/reference/core-plugins/stellate-plugin/purge-rule.md b/docs/docs/reference/core-plugins/stellate-plugin/purge-rule.md new file mode 100644 index 0000000000..ea3c4f2a8d --- /dev/null +++ b/docs/docs/reference/core-plugins/stellate-plugin/purge-rule.md @@ -0,0 +1,95 @@ +--- +title: "PurgeRule" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## PurgeRule + + + +Defines a rule that listens for a particular VendureEvent and uses that to +make calls to the [Stellate Purging API](https://docs.stellate.co/docs/purging-api) via +the provided StellateService instance. + +```ts title="Signature" +class PurgeRule { + eventType: Type + bufferTimeMs: number | undefined + handle(handlerArgs: { events: Event[]; stellateService: StellateService; injector: Injector }) => ; + constructor(config: PurgeRuleConfig) +} +``` + +
+ +### eventType + + + + +### bufferTimeMs + + + + +### handle + +StellateService; injector: Injector }) => `} /> + + +### constructor + +PurgeRuleConfig<Event>) => PurgeRule`} /> + + + + +
+ + +## PurgeRuleConfig + + + +Configures a PurgeRule. + +```ts title="Signature" +interface PurgeRuleConfig { + eventType: Type; + bufferTime?: number; + handler: (handlerArgs: { + events: Event[]; + stellateService: StellateService; + injector: Injector; + }) => void | Promise; +} +``` + +
+ +### eventType + + + +Specifies which VendureEvent will trigger this purge rule. +### bufferTime + + + +How long to buffer events for in milliseconds before executing the handler. This allows +us to efficiently batch calls to the Stellate Purge API. +### handler + +StellateService; injector: Injector; }) => void | Promise<void>`} /> + +The function to invoke when the specified event is published. This function should use the +StellateService instance to call the Stellate Purge API. + + +
diff --git a/docs/docs/reference/core-plugins/stellate-plugin/stellate-plugin-options.md b/docs/docs/reference/core-plugins/stellate-plugin/stellate-plugin-options.md new file mode 100644 index 0000000000..69be120a98 --- /dev/null +++ b/docs/docs/reference/core-plugins/stellate-plugin/stellate-plugin-options.md @@ -0,0 +1,69 @@ +--- +title: "StellatePluginOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## StellatePluginOptions + + + +Configuration options for the StellatePlugin. + +```ts title="Signature" +interface StellatePluginOptions { + serviceName: string; + apiToken: string; + purgeRules: PurgeRule[]; + defaultBufferTimeMs?: number; + devMode?: boolean; + debugLogging?: boolean; +} +``` + +
+ +### serviceName + + + +The Stellate service name, i.e. `.stellate.sh` +### apiToken + + + +The Stellate Purging API token. For instructions on how to generate the token, +see the [Stellate docs](https://docs.stellate.co/docs/purging-api#authentication) +### purgeRules + +PurgeRule[]`} /> + +An array of PurgeRule instances which are used to define how the plugin will +respond to Vendure events in order to trigger calls to the Stellate Purging API. +### defaultBufferTimeMs + + + +When events are published, the PurgeRules will buffer those events in order to efficiently +batch requests to the Stellate Purging API. You may wish to change the default, e.g. if you are +running in a serverless environment and cannot introduce pauses after the main request has completed. +### devMode + + + +When set to `true`, calls will not be made to the Stellate Purge API. +### debugLogging + + + +If set to true, the plugin will log the calls that would be made +to the Stellate Purge API. Note, this generates a +lot of debug-level logging. + + +
diff --git a/docs/docs/reference/core-plugins/stellate-plugin/stellate-service.md b/docs/docs/reference/core-plugins/stellate-plugin/stellate-service.md new file mode 100644 index 0000000000..8481a09a59 --- /dev/null +++ b/docs/docs/reference/core-plugins/stellate-plugin/stellate-service.md @@ -0,0 +1,75 @@ +--- +title: "StellateService" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## StellateService + + + +The StellateService is used to purge the Stellate cache when certain events occur. + +```ts title="Signature" +class StellateService { + constructor(options: StellatePluginOptions) + purgeProducts(products: Product[]) => ; + purgeProductVariants(productVariants: ProductVariant[]) => ; + purgeSearchResults(items: Array) => ; + purgeAllOfType(type: CachedType) => ; + purgeCollections(collections: Collection[]) => ; + purgeSearchResponseCacheIdentifiers(collections: Collection[]) => ; + purge(type: CachedType, keys?: ID[], keyName: = 'id') => ; +} +``` + +
+ +### constructor + +StellatePluginOptions) => StellateService`} /> + + +### purgeProducts + +Product[]) => `} /> + +Purges the cache for the given Products. +### purgeProductVariants + +ProductVariant[]) => `} /> + +Purges the cache for the given ProductVariants. +### purgeSearchResults + +ProductVariant | Product>) => `} /> + +Purges the cache for SearchResults which contain the given Products or ProductVariants. +### purgeAllOfType + + `} /> + +Purges the entire cache for the given type. +### purgeCollections + +Collection[]) => `} /> + +Purges the cache for the given Collections. +### purgeSearchResponseCacheIdentifiers + +Collection[]) => `} /> + +Purges the cache of SearchResults for the given Collections based on slug. +### purge + +ID[], keyName: = 'id') => `} /> + +Purges the cache for the given type and keys. + + +
diff --git a/docs/docs/reference/graphql-api/admin/enums.md b/docs/docs/reference/graphql-api/admin/enums.md index 4762c3d12f..50b44736ed 100644 --- a/docs/docs/reference/graphql-api/admin/enums.md +++ b/docs/docs/reference/graphql-api/admin/enums.md @@ -1,8 +1,6 @@ --- title: "Enums" -weight: 5 -date: 2023-07-21T15:33:44.314Z -showtoc: true +isDefaultIndex: false generated: true --- diff --git a/docs/docs/reference/graphql-api/admin/input-types.md b/docs/docs/reference/graphql-api/admin/input-types.md index 302d739c24..cf1da8e302 100644 --- a/docs/docs/reference/graphql-api/admin/input-types.md +++ b/docs/docs/reference/graphql-api/admin/input-types.md @@ -1,8 +1,6 @@ --- title: "Input Objects" -weight: 4 -date: 2023-07-21T15:33:44.314Z -showtoc: true +isDefaultIndex: false generated: true --- @@ -1258,6 +1256,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
perCustomerUsageLimit: Int
+
usageLimit: Int
+ @@ -1822,6 +1822,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
languageCode: StringOperators
+
facetId: IDOperators
+ @@ -1877,6 +1879,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
updatedAt: SortOrder
+
facetId: SortOrder
+
name: SortOrder
code: SortOrder
@@ -2860,6 +2864,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
perCustomerUsageLimit: NumberOperators
+
usageLimit: NumberOperators
+
description: StringOperators
@@ -2925,6 +2931,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
perCustomerUsageLimit: SortOrder
+
usageLimit: SortOrder
+
name: SortOrder
description: SortOrder
@@ -4469,6 +4477,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
perCustomerUsageLimit: Int
+
usageLimit: Int
+ diff --git a/docs/docs/reference/graphql-api/admin/mutations.md b/docs/docs/reference/graphql-api/admin/mutations.md index 7e427d10d8..ecad14e2e4 100644 --- a/docs/docs/reference/graphql-api/admin/mutations.md +++ b/docs/docs/reference/graphql-api/admin/mutations.md @@ -1,8 +1,6 @@ --- title: "Mutations" -weight: 2 -date: 2023-07-21T15:33:44.314Z -showtoc: true +isDefaultIndex: false generated: true --- diff --git a/docs/docs/reference/graphql-api/admin/object-types.md b/docs/docs/reference/graphql-api/admin/object-types.md index 37406a5072..55d33640c5 100644 --- a/docs/docs/reference/graphql-api/admin/object-types.md +++ b/docs/docs/reference/graphql-api/admin/object-types.md @@ -1,8 +1,6 @@ --- title: "Types" -weight: 3 -date: 2023-07-21T15:33:44.314Z -showtoc: true +isDefaultIndex: false generated: true --- @@ -1126,6 +1124,11 @@ import MemberDescription from '@site/src/components/MemberDescription';
values: [FacetValue!]!
+
"""
+
Returns a paginated, sortable, filterable list of the Facet's values. Added in v2.1.0.
+
"""
+ +
translations: [FacetTranslation!]!
customFields: JSON
@@ -1200,6 +1203,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
facet: Facet!
+
facetId: ID!
+
name: String!
code: String!
@@ -2265,6 +2270,9 @@ import MemberDescription from '@site/src/components/MemberDescription';
"""
proratedUnitPriceWithTax: Money!
+
"""
+
The quantity of items purchased
+
"""
quantity: Int!
"""
@@ -2982,7 +2990,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; {
currencyCode: CurrencyCode!
-
price: Int!
+
price: Money!
}
@@ -3026,6 +3034,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
perCustomerUsageLimit: Int
+
usageLimit: Int
+
name: String!
description: String!
diff --git a/docs/docs/reference/graphql-api/admin/queries.md b/docs/docs/reference/graphql-api/admin/queries.md index 1e81e348f8..b1f27b9801 100644 --- a/docs/docs/reference/graphql-api/admin/queries.md +++ b/docs/docs/reference/graphql-api/admin/queries.md @@ -1,8 +1,6 @@ --- title: "Queries" -weight: 1 -date: 2023-07-21T15:33:44.314Z -showtoc: true +isDefaultIndex: false generated: true --- diff --git a/docs/docs/reference/graphql-api/shop/enums.md b/docs/docs/reference/graphql-api/shop/enums.md index 55bac18f47..e104ec8544 100644 --- a/docs/docs/reference/graphql-api/shop/enums.md +++ b/docs/docs/reference/graphql-api/shop/enums.md @@ -1,8 +1,6 @@ --- title: "Enums" -weight: 5 -date: 2023-07-21T15:33:42.677Z -showtoc: true +isDefaultIndex: false generated: true --- diff --git a/docs/docs/reference/graphql-api/shop/input-types.md b/docs/docs/reference/graphql-api/shop/input-types.md index 5be6f1ee6e..1cd14516c1 100644 --- a/docs/docs/reference/graphql-api/shop/input-types.md +++ b/docs/docs/reference/graphql-api/shop/input-types.md @@ -1,8 +1,6 @@ --- title: "Input Objects" -weight: 4 -date: 2023-07-21T15:33:42.677Z -showtoc: true +isDefaultIndex: false generated: true --- @@ -473,6 +471,87 @@ import MemberDescription from '@site/src/components/MemberDescription';
or: [ID!]
+
}
+ + + +## FacetValueFilterParameter + +
+
input FacetValueFilterParameter + {
+ + +
createdAt: DateOperators
+ +
updatedAt: DateOperators
+ +
languageCode: StringOperators
+ +
facetId: IDOperators
+ + + + + + +
}
+ +
+ +## FacetValueListOptions + +
+
input FacetValueListOptions + {
+
"""
+
Skips the first n results, for use in pagination
+
"""
+
skip: Int
+ +
"""
+
Takes n results, for use in pagination
+
"""
+
take: Int
+ +
"""
+
Specifies which properties to sort the results by
+
"""
+ + +
"""
+
Allows the results to be filtered
+
"""
+ + +
"""
+
Specifies whether multiple "filter" arguments should be combines with a logical AND or OR operation. Defaults to AND.
+
"""
+
filterOperator: LogicalOperator
+ + +
}
+ +
+ +## FacetValueSortParameter + +
+
input FacetValueSortParameter + {
+ + +
createdAt: SortOrder
+ +
updatedAt: SortOrder
+ +
facetId: SortOrder
+ +
name: SortOrder
+ +
code: SortOrder
+ +
}
diff --git a/docs/docs/reference/graphql-api/shop/mutations.md b/docs/docs/reference/graphql-api/shop/mutations.md index 78317d83e3..8278fb2c13 100644 --- a/docs/docs/reference/graphql-api/shop/mutations.md +++ b/docs/docs/reference/graphql-api/shop/mutations.md @@ -1,8 +1,6 @@ --- title: "Mutations" -weight: 2 -date: 2023-07-21T15:33:42.677Z -showtoc: true +isDefaultIndex: false generated: true --- diff --git a/docs/docs/reference/graphql-api/shop/object-types.md b/docs/docs/reference/graphql-api/shop/object-types.md index 73c7083d89..41bfb4ce99 100644 --- a/docs/docs/reference/graphql-api/shop/object-types.md +++ b/docs/docs/reference/graphql-api/shop/object-types.md @@ -1,8 +1,6 @@ --- title: "Types" -weight: 3 -date: 2023-07-21T15:33:42.677Z -showtoc: true +isDefaultIndex: false generated: true --- @@ -795,6 +793,11 @@ import MemberDescription from '@site/src/components/MemberDescription';
values: [FacetValue!]!
+
"""
+
Returns a paginated, sortable, filterable list of the Facet's values. Added in v2.1.0.
+
"""
+ +
translations: [FacetTranslation!]!
customFields: JSON
@@ -850,6 +853,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
facet: Facet!
+
facetId: ID!
+
name: String!
code: String!
@@ -859,6 +864,19 @@ import MemberDescription from '@site/src/components/MemberDescription';
customFields: JSON
+
}
+ + +## FacetValueList + +
+
type FacetValueList + {
+
items: [FacetValue!]!
+ +
totalItems: Int!
+ +
}
@@ -1622,6 +1640,9 @@ import MemberDescription from '@site/src/components/MemberDescription';
"""
proratedUnitPriceWithTax: Money!
+
"""
+
The quantity of items purchased
+
"""
quantity: Int!
"""
@@ -2288,6 +2309,8 @@ import MemberDescription from '@site/src/components/MemberDescription';
perCustomerUsageLimit: Int
+
usageLimit: Int
+
name: String!
description: String!
diff --git a/docs/docs/reference/graphql-api/shop/queries.md b/docs/docs/reference/graphql-api/shop/queries.md index c5d3d803b6..fc6d494a36 100644 --- a/docs/docs/reference/graphql-api/shop/queries.md +++ b/docs/docs/reference/graphql-api/shop/queries.md @@ -1,8 +1,6 @@ --- title: "Queries" -weight: 1 -date: 2023-07-21T15:33:42.677Z -showtoc: true +isDefaultIndex: false generated: true --- diff --git a/docs/docs/reference/typescript-api/common/permission.md b/docs/docs/reference/typescript-api/common/permission.md index 1b1aaa9024..36e0ec9366 100644 --- a/docs/docs/reference/typescript-api/common/permission.md +++ b/docs/docs/reference/typescript-api/common/permission.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## Permission - + Permissions for administrators and customers. Used to control access to GraphQL resolvers via the Allow decorator. diff --git a/docs/docs/reference/typescript-api/errors/error-result-union.md b/docs/docs/reference/typescript-api/errors/error-result-union.md new file mode 100644 index 0000000000..1169cdc1b2 --- /dev/null +++ b/docs/docs/reference/typescript-api/errors/error-result-union.md @@ -0,0 +1,34 @@ +--- +title: "ErrorResultUnion" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## ErrorResultUnion + + + +Used to construct a TypeScript return type for a query or mutation which, in the GraphQL schema, +returns a union type composed of a success result (e.g. Order) plus one or more ErrorResult +types. + +Since the TypeScript entities do not correspond 1-to-1 with their GraphQL type counterparts, +we use this type to substitute them. + +*Example* + +```ts +type UpdateOrderItemsResult = Order | OrderModificationError | OrderLimitError | NegativeQuantityError; +type T1 = ErrorResultUnion; +// T1 = VendureEntityOrder | OrderModificationError | OrderLimitError | NegativeQuantityError; +``` + +```ts title="Signature" +type ErrorResultUnion = | JustErrorResults + | E +``` diff --git a/docs/docs/reference/typescript-api/errors/is-graph-ql-error-result.md b/docs/docs/reference/typescript-api/errors/is-graph-ql-error-result.md new file mode 100644 index 0000000000..2aa117ad05 --- /dev/null +++ b/docs/docs/reference/typescript-api/errors/is-graph-ql-error-result.md @@ -0,0 +1,44 @@ +--- +title: "IsGraphQlErrorResult" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + +## isGraphQlErrorResult + + + +Returns true if the ErrorResultUnion is actually an ErrorResult type. This is useful when dealing with +certain internal service method that return an ErrorResultUnion. + +*Example* + +```ts +import { isGraphQlErrorResult } from '@vendure/core'; + +// ... + +const transitionResult = await this.orderService.transitionToState(ctx, order.id, newState); +if (isGraphQlErrorResult(transitionResult)) { + // The transition failed with an ErrorResult + throw transitionResult; +} else { + // TypeScript will correctly infer the type of `transitionResult` to be `Order` + return transitionResult; +} +``` + +```ts title="Signature" +function isGraphQlErrorResult(input: T): input is JustErrorResults +``` +Parameters + +### input + + + diff --git a/docs/docs/reference/typescript-api/events/vendure-entity-event.md b/docs/docs/reference/typescript-api/events/vendure-entity-event.md index 790f830596..a5d825a3c4 100644 --- a/docs/docs/reference/typescript-api/events/vendure-entity-event.md +++ b/docs/docs/reference/typescript-api/events/vendure-entity-event.md @@ -11,10 +11,9 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## VendureEntityEvent - + The base class for all entity events used by the EventBus system. -* For event type `'updated'` the entity is the one before applying the patch (if not documented otherwise). * For event type `'deleted'` the input will most likely be an `id: ID` ```ts title="Signature" diff --git a/docs/docs/reference/typescript-api/orders/default-guest-checkout-strategy.md b/docs/docs/reference/typescript-api/orders/default-guest-checkout-strategy.md index 58a2fd8ddb..9e87458b56 100644 --- a/docs/docs/reference/typescript-api/orders/default-guest-checkout-strategy.md +++ b/docs/docs/reference/typescript-api/orders/default-guest-checkout-strategy.md @@ -57,7 +57,7 @@ class DefaultGuestCheckoutStrategy implements GuestCheckoutStrategy { ### setCustomerForOrder -RequestContext, order: Order, input: CreateCustomerInput) => Promise<ErrorResultUnion<SetCustomerForOrderResult, Customer>>`} /> +RequestContext, order: Order, input: CreateCustomerInput) => Promise<ErrorResultUnion<SetCustomerForOrderResult, Customer>>`} /> diff --git a/docs/docs/reference/typescript-api/orders/guest-checkout-strategy.md b/docs/docs/reference/typescript-api/orders/guest-checkout-strategy.md index b2f28b3579..523fd7f3db 100644 --- a/docs/docs/reference/typescript-api/orders/guest-checkout-strategy.md +++ b/docs/docs/reference/typescript-api/orders/guest-checkout-strategy.md @@ -51,7 +51,7 @@ interface GuestCheckoutStrategy extends InjectableStrategy { ### setCustomerForOrder -RequestContext, order: Order, input: CreateCustomerInput) => | ErrorResultUnion<SetCustomerForOrderResult, Customer> | Promise<ErrorResultUnion<SetCustomerForOrderResult, Customer>>`} /> +RequestContext, order: Order, input: CreateCustomerInput) => | ErrorResultUnion<SetCustomerForOrderResult, Customer> | Promise<ErrorResultUnion<SetCustomerForOrderResult, Customer>>`} /> This method is called when the `setCustomerForOrder` mutation is executed. It should return either a Customer object or an ErrorResult. diff --git a/docs/docs/reference/typescript-api/orders/order-process.md b/docs/docs/reference/typescript-api/orders/order-process.md index 6e5676e178..2371270df0 100644 --- a/docs/docs/reference/typescript-api/orders/order-process.md +++ b/docs/docs/reference/typescript-api/orders/order-process.md @@ -196,7 +196,7 @@ Parameters ## defaultOrderProcess - + This is the built-in OrderProcess that ships with Vendure. A customized version of this process can be created using the configureDefaultOrderProcess function, which allows you to pass in an object diff --git a/docs/docs/reference/typescript-api/request/request-context-service.md b/docs/docs/reference/typescript-api/request/request-context-service.md index e9f33a9df9..7263b1a7cf 100644 --- a/docs/docs/reference/typescript-api/request/request-context-service.md +++ b/docs/docs/reference/typescript-api/request/request-context-service.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RequestContextService - + Creates new RequestContext instances. diff --git a/docs/docs/reference/typescript-api/services/channel-service.md b/docs/docs/reference/typescript-api/services/channel-service.md index d7b05aa64a..e3f07cc147 100644 --- a/docs/docs/reference/typescript-api/services/channel-service.md +++ b/docs/docs/reference/typescript-api/services/channel-service.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ChannelService - + Contains methods relating to Channel entities. @@ -90,12 +90,12 @@ Returns the default Channel. ### create -RequestContext, input: CreateChannelInput) => Promise<ErrorResultUnion<CreateChannelResult, Channel>>`} /> +RequestContext, input: CreateChannelInput) => Promise<ErrorResultUnion<CreateChannelResult, Channel>>`} /> ### update -RequestContext, input: UpdateChannelInput) => Promise<ErrorResultUnion<UpdateChannelResult, Channel>>`} /> +RequestContext, input: UpdateChannelInput) => Promise<ErrorResultUnion<UpdateChannelResult, Channel>>`} /> ### delete diff --git a/docs/docs/reference/typescript-api/services/collection-service.md b/docs/docs/reference/typescript-api/services/collection-service.md index 28af8bb05b..9171ae4a1b 100644 --- a/docs/docs/reference/typescript-api/services/collection-service.md +++ b/docs/docs/reference/typescript-api/services/collection-service.md @@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CollectionService - + Contains methods relating to Collection entities. diff --git a/docs/docs/reference/typescript-api/services/customer-service.md b/docs/docs/reference/typescript-api/services/customer-service.md index 2e91471aa6..44884e197f 100644 --- a/docs/docs/reference/typescript-api/services/customer-service.md +++ b/docs/docs/reference/typescript-api/services/customer-service.md @@ -31,8 +31,8 @@ class CustomerService { refreshVerificationToken(ctx: RequestContext, emailAddress: string) => Promise; verifyCustomerEmailAddress(ctx: RequestContext, verificationToken: string, password?: string) => Promise>; requestPasswordReset(ctx: RequestContext, emailAddress: string) => Promise; - resetPassword(ctx: RequestContext, passwordResetToken: string, password: string) => Promise< - User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError + resetPassword(ctx: RequestContext, passwordResetToken: string, password: string) => Promise< + User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError >; requestUpdateEmailAddress(ctx: RequestContext, userId: ID, newEmailAddress: string) => Promise; updateEmailAddress(ctx: RequestContext, token: string) => Promise; @@ -69,8 +69,8 @@ class CustomerService { RequestContext, userId: ID, filterOnChannel: = true) => Promise<Customer | undefined>`} /> -Returns the Customer entity associated with the given userId, if one exists. -Setting `filterOnChannel` to `true` will limit the results to Customers which are assigned +Returns the Customer entity associated with the given userId, if one exists. +Setting `filterOnChannel` to `true` will limit the results to Customers which are assigned to the current active Channel only. ### findAddressesByCustomerId @@ -84,15 +84,15 @@ Returns all Address Returns a list of all CustomerGroup entities. ### create -RequestContext, input: CreateCustomerInput, password?: string) => Promise<ErrorResultUnion<CreateCustomerResult, Customer>>`} /> - -Creates a new Customer, including creation of a new User with the special `customer` Role. - -If the `password` argument is specified, the Customer will be immediately verified. If not, -then an AccountRegistrationEvent is published, so that the customer can have their -email address verified and set their password in a later step using the `verifyCustomerEmailAddress()` -method. +RequestContext, input: CreateCustomerInput, password?: string) => Promise<ErrorResultUnion<CreateCustomerResult, Customer>>`} /> +Creates a new Customer, including creation of a new User with the special `customer` Role. + +If the `password` argument is specified, the Customer will be immediately verified. If not, +then an AccountRegistrationEvent is published, so that the customer can have their +email address verified and set their password in a later step using the `verifyCustomerEmailAddress()` +method. + This method is intended to be used in admin-created Customer flows. ### update @@ -101,59 +101,59 @@ This method is intended to be used in admin-created Customer flows. ### update -RequestContext, input: UpdateCustomerInput) => Promise<ErrorResultUnion<UpdateCustomerResult, Customer>>`} /> +RequestContext, input: UpdateCustomerInput) => Promise<ErrorResultUnion<UpdateCustomerResult, Customer>>`} /> ### update -RequestContext, input: UpdateCustomerInput | (UpdateCustomerShopInput & { id: ID })) => Promise<ErrorResultUnion<UpdateCustomerResult, Customer>>`} /> +RequestContext, input: UpdateCustomerInput | (UpdateCustomerShopInput & { id: ID })) => Promise<ErrorResultUnion<UpdateCustomerResult, Customer>>`} /> ### registerCustomerAccount RequestContext, input: RegisterCustomerInput) => Promise<RegisterCustomerAccountResult | EmailAddressConflictError | PasswordValidationError>`} /> -Registers a new Customer account with the NativeAuthenticationStrategy and starts -the email verification flow (unless AuthOptions `requireVerification` is set to `false`) -by publishing an AccountRegistrationEvent. - +Registers a new Customer account with the NativeAuthenticationStrategy and starts +the email verification flow (unless AuthOptions `requireVerification` is set to `false`) +by publishing an AccountRegistrationEvent. + This method is intended to be used in storefront Customer-creation flows. ### refreshVerificationToken RequestContext, emailAddress: string) => Promise<void>`} /> -Refreshes a stale email address verification token by generating a new one and +Refreshes a stale email address verification token by generating a new one and publishing a AccountRegistrationEvent. ### verifyCustomerEmailAddress -RequestContext, verificationToken: string, password?: string) => Promise<ErrorResultUnion<VerifyCustomerAccountResult, Customer>>`} /> +RequestContext, verificationToken: string, password?: string) => Promise<ErrorResultUnion<VerifyCustomerAccountResult, Customer>>`} /> -Given a valid verification token which has been published in an AccountRegistrationEvent, this +Given a valid verification token which has been published in an AccountRegistrationEvent, this method is used to set the Customer as `verified` as part of the account registration flow. ### requestPasswordReset RequestContext, emailAddress: string) => Promise<void>`} /> -Publishes a new PasswordResetEvent for the given email address. This event creates +Publishes a new PasswordResetEvent for the given email address. This event creates a token which can be used in the `resetPassword()` method. ### resetPassword -RequestContext, passwordResetToken: string, password: string) => Promise< User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError >`} /> +RequestContext, passwordResetToken: string, password: string) => Promise< User | PasswordResetTokenExpiredError | PasswordResetTokenInvalidError | PasswordValidationError >`} /> -Given a valid password reset token created by a call to the `requestPasswordReset()` method, +Given a valid password reset token created by a call to the `requestPasswordReset()` method, this method will change the Customer's password to that given as the `password` argument. ### requestUpdateEmailAddress RequestContext, userId: ID, newEmailAddress: string) => Promise<boolean | EmailAddressConflictError>`} /> -Publishes a IdentifierChangeRequestEvent for the given User. This event contains a token -which is then used in the `updateEmailAddress()` method to change the email address of the User & +Publishes a IdentifierChangeRequestEvent for the given User. This event contains a token +which is then used in the `updateEmailAddress()` method to change the email address of the User & Customer. ### updateEmailAddress RequestContext, token: string) => Promise<boolean | IdentifierChangeTokenInvalidError | IdentifierChangeTokenExpiredError>`} /> -Given a valid email update token published in a IdentifierChangeRequestEvent, this method +Given a valid email update token published in a IdentifierChangeRequestEvent, this method will update the Customer & User email address. ### createOrUpdate @@ -184,8 +184,8 @@ Creates a new Addre RequestContext, order: Order) => `} /> -If the Customer associated with the given Order does not yet have any Addresses, -this method will create new Address(es) based on the Order's shipping & billing +If the Customer associated with the given Order does not yet have any Addresses, +this method will create new Address(es) based on the Order's shipping & billing addresses. ### addNoteToCustomer diff --git a/docs/docs/reference/typescript-api/services/facet-service.md b/docs/docs/reference/typescript-api/services/facet-service.md index 2ec7eff90a..274e80e484 100644 --- a/docs/docs/reference/typescript-api/services/facet-service.md +++ b/docs/docs/reference/typescript-api/services/facet-service.md @@ -91,7 +91,7 @@ Returns the Facet which contains the given FacetValue id. Assigns Facets to the specified Channel ### removeFacetsFromChannel -RequestContext, input: RemoveFacetsFromChannelInput) => Promise<Array<ErrorResultUnion<RemoveFacetFromChannelResult, Facet>>>`} /> +RequestContext, input: RemoveFacetsFromChannelInput) => Promise<Array<ErrorResultUnion<RemoveFacetFromChannelResult, Facet>>>`} /> Remove Facets from the specified Channel diff --git a/docs/docs/reference/typescript-api/services/order-service.md b/docs/docs/reference/typescript-api/services/order-service.md index 70f0024405..aa70fae38b 100644 --- a/docs/docs/reference/typescript-api/services/order-service.md +++ b/docs/docs/reference/typescript-api/services/order-service.md @@ -166,23 +166,23 @@ User's Customer account. Updates the custom fields of an Order. ### addItemToOrder -RequestContext, orderId: ID, productVariantId: ID, quantity: number, customFields?: { [key: string]: any }) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>`} /> +RequestContext, orderId: ID, productVariantId: ID, quantity: number, customFields?: { [key: string]: any }) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>`} /> Adds an item to the Order, either creating a new OrderLine or incrementing an existing one. ### adjustOrderLine -RequestContext, orderId: ID, orderLineId: ID, quantity: number, customFields?: { [key: string]: any }) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>`} /> +RequestContext, orderId: ID, orderLineId: ID, quantity: number, customFields?: { [key: string]: any }) => Promise<ErrorResultUnion<UpdateOrderItemsResult, Order>>`} /> Adjusts the quantity and/or custom field values of an existing OrderLine. ### removeItemFromOrder -RequestContext, orderId: ID, orderLineId: ID) => Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>>`} /> +RequestContext, orderId: ID, orderLineId: ID) => Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>>`} /> Removes the specified OrderLine from the Order. ### removeAllItemsFromOrder -RequestContext, orderId: ID) => Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>>`} /> +RequestContext, orderId: ID) => Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>>`} /> Removes all OrderLines from the Order. ### addSurchargeToOrder @@ -197,7 +197,7 @@ Adds a Surcharg Removes a Surcharge from the Order. ### applyCouponCode -RequestContext, orderId: ID, couponCode: string) => Promise<ErrorResultUnion<ApplyCouponCodeResult, Order>>`} /> +RequestContext, orderId: ID, couponCode: string) => Promise<ErrorResultUnion<ApplyCouponCodeResult, Order>>`} /> Applies a coupon code to the Order, which should be a valid coupon code as specified in the configuration of an active Promotion. @@ -242,7 +242,7 @@ The quote also includes a price for each method, as determined by the configured Returns an array of quotes stating which PaymentMethods may be used on this Order. ### setShippingMethod -RequestContext, orderId: ID, shippingMethodIds: ID[]) => Promise<ErrorResultUnion<SetOrderShippingMethodResult, Order>>`} /> +RequestContext, orderId: ID, shippingMethodIds: ID[]) => Promise<ErrorResultUnion<SetOrderShippingMethodResult, Order>>`} /> Sets the ShippingMethod to be used on this Order. ### transitionToState @@ -258,7 +258,7 @@ Transitions a Fulfillment to the given state and then transitions the Order stat whether all Fulfillments of the Order are shipped or delivered. ### modifyOrder -RequestContext, input: ModifyOrderInput) => Promise<ErrorResultUnion<ModifyOrderResult, Order>>`} /> +RequestContext, input: ModifyOrderInput) => Promise<ErrorResultUnion<ModifyOrderResult, Order>>`} /> Allows the Order to be modified, which allows several aspects of the Order to be changed: @@ -273,20 +273,20 @@ Order, except history entry and additional payment actions. __Using dryRun option, you must wrap function call in transaction manually.__ ### transitionPaymentToState -RequestContext, paymentId: ID, state: PaymentState) => Promise<ErrorResultUnion<TransitionPaymentToStateResult, Payment>>`} /> +RequestContext, paymentId: ID, state: PaymentState) => Promise<ErrorResultUnion<TransitionPaymentToStateResult, Payment>>`} /> Transitions the given Payment to a new state. If the order totalWithTax price is then covered by Payments, the Order state will be automatically transitioned to `PaymentSettled` or `PaymentAuthorized`. ### addPaymentToOrder -RequestContext, orderId: ID, input: PaymentInput) => Promise<ErrorResultUnion<AddPaymentToOrderResult, Order>>`} /> +RequestContext, orderId: ID, input: PaymentInput) => Promise<ErrorResultUnion<AddPaymentToOrderResult, Order>>`} /> Adds a new Payment to the Order. If the Order totalWithTax is covered by Payments, then the Order state will get automatically transitioned to the `PaymentSettled` or `PaymentAuthorized` state. ### addManualPaymentToOrder -RequestContext, input: ManualPaymentInput) => Promise<ErrorResultUnion<AddManualPaymentToOrderResult, Order>>`} /> +RequestContext, input: ManualPaymentInput) => Promise<ErrorResultUnion<AddManualPaymentToOrderResult, Order>>`} /> This method is used after modifying an existing completed order using the `modifyOrder()` method. If the modifications cause the order total to increase (such as when adding a new OrderLine), then there will be an outstanding charge to @@ -296,19 +296,19 @@ This method allows you to add a new Payment and assumes the actual processing ha dashboard of your payment provider. ### settlePayment -RequestContext, paymentId: ID) => Promise<ErrorResultUnion<SettlePaymentResult, Payment>>`} /> +RequestContext, paymentId: ID) => Promise<ErrorResultUnion<SettlePaymentResult, Payment>>`} /> Settles a payment by invoking the PaymentMethodHandler's `settlePayment()` method. Automatically transitions the Order state if all Payments are settled. ### cancelPayment -RequestContext, paymentId: ID) => Promise<ErrorResultUnion<CancelPaymentResult, Payment>>`} /> +RequestContext, paymentId: ID) => Promise<ErrorResultUnion<CancelPaymentResult, Payment>>`} /> Cancels a payment by invoking the PaymentMethodHandler's `cancelPayment()` method (if defined), and transitions the Payment to the `Cancelled` state. ### createFulfillment -RequestContext, input: FulfillOrderInput) => Promise<ErrorResultUnion<AddFulfillmentToOrderResult, Fulfillment>>`} /> +RequestContext, input: FulfillOrderInput) => Promise<ErrorResultUnion<AddFulfillmentToOrderResult, Fulfillment>>`} /> Creates a new Fulfillment associated with the given Order and OrderItems. ### getOrderFulfillments @@ -323,13 +323,13 @@ Returns an array of all Fulfillments associated with the Order. Returns an array of all Surcharges associated with the Order. ### cancelOrder -RequestContext, input: CancelOrderInput) => Promise<ErrorResultUnion<CancelOrderResult, Order>>`} /> +RequestContext, input: CancelOrderInput) => Promise<ErrorResultUnion<CancelOrderResult, Order>>`} /> Cancels an Order by transitioning it to the `Cancelled` state. If stock is being tracked for the ProductVariants in the Order, then new StockMovements will be created to correct the stock levels. ### refundOrder -RequestContext, input: RefundOrderInput) => Promise<ErrorResultUnion<RefundOrderResult, Refund>>`} /> +RequestContext, input: RefundOrderInput) => Promise<ErrorResultUnion<RefundOrderResult, Refund>>`} /> Creates a {@link Refund} against the order and in doing so invokes the `createRefund()` method of the PaymentMethodHandler. diff --git a/docs/docs/reference/typescript-api/services/product-service.md b/docs/docs/reference/typescript-api/services/product-service.md index 4eb644aebf..0b2191b854 100644 --- a/docs/docs/reference/typescript-api/services/product-service.md +++ b/docs/docs/reference/typescript-api/services/product-service.md @@ -11,13 +11,13 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ProductService - + Contains methods relating to Product entities. ```ts title="Signature" class ProductService { - constructor(connection: TransactionalConnection, channelService: ChannelService, roleService: RoleService, assetService: AssetService, productVariantService: ProductVariantService, facetValueService: FacetValueService, taxRateService: TaxRateService, collectionService: CollectionService, listQueryBuilder: ListQueryBuilder, translatableSaver: TranslatableSaver, eventBus: EventBus, slugValidator: SlugValidator, customFieldRelationService: CustomFieldRelationService, translator: TranslatorService, productOptionGroupService: ProductOptionGroupService) + constructor(connection: TransactionalConnection, channelService: ChannelService, assetService: AssetService, productVariantService: ProductVariantService, facetValueService: FacetValueService, listQueryBuilder: ListQueryBuilder, translatableSaver: TranslatableSaver, eventBus: EventBus, slugValidator: SlugValidator, customFieldRelationService: CustomFieldRelationService, translator: TranslatorService, productOptionGroupService: ProductOptionGroupService) findAll(ctx: RequestContext, options?: ListQueryOptions, relations?: RelationPaths) => Promise>>; findOne(ctx: RequestContext, productId: ID, relations?: RelationPaths) => Promise | undefined>; findByIds(ctx: RequestContext, productIds: ID[], relations?: RelationPaths) => Promise>>; @@ -38,7 +38,7 @@ class ProductService { ### constructor -TransactionalConnection, channelService: ChannelService, roleService: RoleService, assetService: AssetService, productVariantService: ProductVariantService, facetValueService: FacetValueService, taxRateService: TaxRateService, collectionService: CollectionService, listQueryBuilder: ListQueryBuilder, translatableSaver: TranslatableSaver, eventBus: EventBus, slugValidator: SlugValidator, customFieldRelationService: CustomFieldRelationService, translator: TranslatorService, productOptionGroupService: ProductOptionGroupService) => ProductService`} /> +TransactionalConnection, channelService: ChannelService, assetService: AssetService, productVariantService: ProductVariantService, facetValueService: FacetValueService, listQueryBuilder: ListQueryBuilder, translatableSaver: TranslatableSaver, eventBus: EventBus, slugValidator: SlugValidator, customFieldRelationService: CustomFieldRelationService, translator: TranslatorService, productOptionGroupService: ProductOptionGroupService) => ProductService`} /> ### findAll @@ -107,7 +107,7 @@ each of the Product's variants, and will assign the Product's Assets to the Chan ### removeOptionGroupFromProduct -RequestContext, productId: ID, optionGroupId: ID, force?: boolean) => Promise<ErrorResultUnion<RemoveOptionGroupFromProductResult, Translated<Product>>>`} /> +RequestContext, productId: ID, optionGroupId: ID, force?: boolean) => Promise<ErrorResultUnion<RemoveOptionGroupFromProductResult, Translated<Product>>>`} /> diff --git a/docs/docs/reference/typescript-api/services/promotion-service.md b/docs/docs/reference/typescript-api/services/promotion-service.md index 1e622413bf..4bc9b09a93 100644 --- a/docs/docs/reference/typescript-api/services/promotion-service.md +++ b/docs/docs/reference/typescript-api/services/promotion-service.md @@ -76,12 +76,12 @@ class PromotionService { ### createPromotion -RequestContext, input: CreatePromotionInput) => Promise<ErrorResultUnion<CreatePromotionResult, Promotion>>`} /> +RequestContext, input: CreatePromotionInput) => Promise<ErrorResultUnion<CreatePromotionResult, Promotion>>`} /> ### updatePromotion -RequestContext, input: UpdatePromotionInput) => Promise<ErrorResultUnion<UpdatePromotionResult, Promotion>>`} /> +RequestContext, input: UpdatePromotionInput) => Promise<ErrorResultUnion<UpdatePromotionResult, Promotion>>`} /> ### softDeletePromotion diff --git a/docs/docs/reference/typescript-api/services/user-service.md b/docs/docs/reference/typescript-api/services/user-service.md index b75a446f5f..f239636cef 100644 --- a/docs/docs/reference/typescript-api/services/user-service.md +++ b/docs/docs/reference/typescript-api/services/user-service.md @@ -88,7 +88,7 @@ Sets the RequestContext, verificationToken: string, password?: string) => Promise<ErrorResultUnion<VerifyCustomerAccountResult, User>>`} /> +RequestContext, verificationToken: string, password?: string) => Promise<ErrorResultUnion<VerifyCustomerAccountResult, User>>`} /> Verifies a verificationToken by looking for a User which has previously had it set using the `setVerificationToken()` method, and checks that the token is valid and has not expired. diff --git a/docs/sidebars.js b/docs/sidebars.js index 5a83793616..9cb905de02 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -267,6 +267,12 @@ const sidebars = { link: { type: 'doc', id: 'reference/core-plugins/payments-plugin/index' }, items: [{ type: 'autogenerated', dirName: 'reference/core-plugins/payments-plugin' }], }, + { + type: 'category', + label: 'StellatePlugin', + link: { type: 'doc', id: 'reference/core-plugins/stellate-plugin/index' }, + items: [{ type: 'autogenerated', dirName: 'reference/core-plugins/stellate-plugin' }], + }, ], }, { diff --git a/package.json b/package.json index 08099b82bd..cf10865561 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "format": "prettier --write --html-whitespace-sensitivity ignore", "docs:generate-typescript-docs": "ts-node scripts/docs/generate-typescript-docs.ts", "docs:generate-graphql-docs": "ts-node scripts/docs/generate-graphql-docs.ts --api=shop && ts-node scripts/docs/generate-graphql-docs.ts --api=admin", - "docs:update-build-info": "ts-node scripts/docs/update-build-info.ts", - "docs:build": "yarn docs:generate-graphql-docs && yarn docs:generate-typescript-docs && yarn docs:update-build-info", + "docs:build": "yarn docs:generate-graphql-docs && yarn docs:generate-typescript-docs", "codegen": "tsc -p scripts/codegen/plugins && ts-node scripts/codegen/generate-graphql-types.ts", "version": "yarn check-imports && yarn check-angular-versions && yarn build && yarn check-core-type-defs && yarn generate-changelog && git add CHANGELOG* && git add */version.ts", "dev-server:start": "cd packages/dev-server && yarn start", diff --git a/packages/stellate-plugin/.gitignore b/packages/stellate-plugin/.gitignore new file mode 100644 index 0000000000..368c3fdfbe --- /dev/null +++ b/packages/stellate-plugin/.gitignore @@ -0,0 +1,3 @@ +yarn-error.log +lib +e2e/__data__/*.sqlite diff --git a/packages/stellate-plugin/README.md b/packages/stellate-plugin/README.md new file mode 100644 index 0000000000..bd4b67cdc7 --- /dev/null +++ b/packages/stellate-plugin/README.md @@ -0,0 +1,7 @@ +# Vendure Stellate Plugin + +Integrates your Vendure server with the [Stellate](TaxRateEvent) GraphQL API cache. + +`npm install @vendure/stellate-plugin` + +For documentation, see [docs.vendure.io/typescript-api/core-plugins/stellate-plugin/](https://docs.vendure.io/typescript-api/core-plugins/stellate-plugin/) diff --git a/packages/stellate-plugin/index.ts b/packages/stellate-plugin/index.ts new file mode 100644 index 0000000000..e270ae43f8 --- /dev/null +++ b/packages/stellate-plugin/index.ts @@ -0,0 +1,6 @@ +export * from './src/stellate-plugin'; +export * from './src/service/stellate.service'; +export * from './src/default-purge-rules'; +export * from './src/purge-rule'; +export * from './src/types'; +export * from './src/constants'; diff --git a/packages/stellate-plugin/package.json b/packages/stellate-plugin/package.json new file mode 100644 index 0000000000..738cfc23a1 --- /dev/null +++ b/packages/stellate-plugin/package.json @@ -0,0 +1,27 @@ +{ + "name": "@vendure/stellate-plugin", + "version": "2.1.4", + "license": "MIT", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "files": [ + "lib/**/*" + ], + "scripts": { + "watch": "tsc -p ./tsconfig.build.json --watch", + "build": "rimraf lib && tsc -p ./tsconfig.build.json", + "lint": "eslint --fix ." + }, + "homepage": "https://www.vendure.io", + "funding": "https://github.com/sponsors/michaelbromley", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "node-fetch": "^2.7.0" + }, + "devDependencies": { + "@vendure/common": "^2.1.4", + "@vendure/core": "^2.1.4" + } +} diff --git a/packages/stellate-plugin/src/api/api-extensions.ts b/packages/stellate-plugin/src/api/api-extensions.ts new file mode 100644 index 0000000000..239c6f3679 --- /dev/null +++ b/packages/stellate-plugin/src/api/api-extensions.ts @@ -0,0 +1,17 @@ +import gql from 'graphql-tag'; + +export const shopApiExtensions = gql` + """ + This type is here to allow us to easily purge the Stellate cache + of any search results where the collectionSlug is used. We cannot rely on + simply purging the SearchResult type, because in the case of an empty 'items' + array, Stellate cannot know that that particular query now needs to be purged. + """ + type SearchResponseCacheIdentifier { + collectionSlug: String + } + + extend type SearchResponse { + cacheIdentifier: SearchResponseCacheIdentifier + } +`; diff --git a/packages/stellate-plugin/src/api/search-response.resolver.ts b/packages/stellate-plugin/src/api/search-response.resolver.ts new file mode 100644 index 0000000000..4e522480ff --- /dev/null +++ b/packages/stellate-plugin/src/api/search-response.resolver.ts @@ -0,0 +1,11 @@ +import { Info, ResolveField, Resolver } from '@nestjs/graphql'; +import { GraphQLResolveInfo } from 'graphql/type'; + +@Resolver('SearchResponse') +export class SearchResponseFieldResolver { + @ResolveField() + cacheIdentifier(@Info() info: GraphQLResolveInfo) { + const collectionSlug = (info.variableValues.input as any)?.collectionSlug; + return { collectionSlug }; + } +} diff --git a/packages/stellate-plugin/src/constants.ts b/packages/stellate-plugin/src/constants.ts new file mode 100644 index 0000000000..80409e0e7b --- /dev/null +++ b/packages/stellate-plugin/src/constants.ts @@ -0,0 +1,2 @@ +export const STELLATE_PLUGIN_OPTIONS = 'STELLATE_PLUGIN_OPTIONS'; +export const loggerCtx = 'StellatePlugin'; diff --git a/packages/stellate-plugin/src/default-purge-rules.ts b/packages/stellate-plugin/src/default-purge-rules.ts new file mode 100644 index 0000000000..780ea40057 --- /dev/null +++ b/packages/stellate-plugin/src/default-purge-rules.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { + CollectionEvent, + CollectionModificationEvent, + Logger, + ProductChannelEvent, + ProductEvent, + ProductVariantChannelEvent, + ProductVariantEvent, + StockMovementEvent, + TaxRateEvent, +} from '@vendure/core'; + +import { loggerCtx } from './constants'; +import { PurgeRule } from './purge-rule'; + +export const purgeProductsOnProductEvent = new PurgeRule({ + eventType: ProductEvent, + handler: ({ events, stellateService }) => { + const products = events.map(e => e.product); + stellateService.purgeProducts(products); + stellateService.purgeSearchResults(products); + }, +}); + +export const purgeProductVariantsOnProductVariantEvent = new PurgeRule({ + eventType: ProductVariantEvent, + handler: ({ events, stellateService }) => { + const variants = events.map(e => e.variants).flat(); + stellateService.purgeProductVariants(variants); + stellateService.purgeSearchResults(variants); + }, +}); + +export const purgeProductsOnChannelEvent = new PurgeRule({ + eventType: ProductChannelEvent, + handler: ({ events, stellateService }) => { + const products = events.map(e => e.product); + stellateService.purgeProducts(products); + stellateService.purgeSearchResults(products); + }, +}); + +export const purgeProductVariantsOnChannelEvent = new PurgeRule({ + eventType: ProductVariantChannelEvent, + handler: ({ events, stellateService }) => { + const variants = events.map(e => e.productVariant); + stellateService.purgeProductVariants(variants); + stellateService.purgeSearchResults(variants); + }, +}); + +export const purgeProductVariantsOnStockMovementEvent = new PurgeRule({ + eventType: StockMovementEvent, + handler: ({ events, stellateService }) => { + const variants = events.map(e => e.stockMovements.map(m => m.productVariant)).flat(); + stellateService.purgeProductVariants(variants); + stellateService.purgeSearchResults(variants); + }, +}); + +export const purgeCollectionsOnCollectionModificationEvent = new PurgeRule({ + eventType: CollectionModificationEvent, + handler: ({ events, stellateService }) => { + const collectionsToPurge = events.filter(e => e.productVariantIds.length).map(e => e.collection); + Logger.debug( + `purgeCollectionsOnCollectionModificationEvent, collectionsToPurge: ${collectionsToPurge + .map(c => c.id) + .join(', ')}`, + loggerCtx, + ); + if (collectionsToPurge.length) { + stellateService.purgeCollections(collectionsToPurge); + stellateService.purgeSearchResponseCacheIdentifiers(collectionsToPurge); + } + }, +}); + +export const purgeCollectionsOnCollectionEvent = new PurgeRule({ + eventType: CollectionEvent, + handler: ({ events, stellateService }) => { + const collections = events.map(e => e.entity); + stellateService.purgeCollections(collections); + }, +}); + +export const purgeAllOnTaxRateEvent = new PurgeRule({ + eventType: TaxRateEvent, + handler: ({ stellateService }) => { + stellateService.purgeAllOfType('ProductVariant'); + stellateService.purgeAllOfType('Product'); + stellateService.purgeAllOfType('SearchResponse'); + }, +}); + +export const defaultPurgeRules = [ + purgeAllOnTaxRateEvent, + purgeCollectionsOnCollectionEvent, + purgeCollectionsOnCollectionModificationEvent, + purgeProductsOnChannelEvent, + purgeProductsOnProductEvent, + purgeProductVariantsOnChannelEvent, + purgeProductVariantsOnProductVariantEvent, + purgeProductVariantsOnStockMovementEvent, +]; diff --git a/packages/stellate-plugin/src/purge-rule.ts b/packages/stellate-plugin/src/purge-rule.ts new file mode 100644 index 0000000000..d73f9ca37b --- /dev/null +++ b/packages/stellate-plugin/src/purge-rule.ts @@ -0,0 +1,60 @@ +import { Type } from '@vendure/common/lib/shared-types'; +import { VendureEvent, Injector } from '@vendure/core'; + +import { StellateService } from './service/stellate.service'; + +/** + * @description + * Configures a {@link PurgeRule}. + * + * @docsCategory core plugins/StellatePlugin + * @docsPage PurgeRule + */ +export interface PurgeRuleConfig { + /** + * @description + * Specifies which VendureEvent will trigger this purge rule. + */ + eventType: Type; + /** + * @description + * How long to buffer events for in milliseconds before executing the handler. This allows + * us to efficiently batch calls to the Stellate Purge API. + * + * @default 5000 + */ + bufferTime?: number; + /** + * @description + * The function to invoke when the specified event is published. This function should use the + * {@link StellateService} instance to call the Stellate Purge API. + */ + handler: (handlerArgs: { + events: Event[]; + stellateService: StellateService; + injector: Injector; + }) => void | Promise; +} + +/** + * @description + * Defines a rule that listens for a particular VendureEvent and uses that to + * make calls to the [Stellate Purging API](https://docs.stellate.co/docs/purging-api) via + * the provided {@link StellateService} instance. + * + * @docsCategory core plugins/StellatePlugin + * @docsPage PurgeRule + * @docsWeight 0 + */ +export class PurgeRule { + get eventType(): Type { + return this.config.eventType; + } + get bufferTimeMs(): number | undefined { + return this.config.bufferTime; + } + handle(handlerArgs: { events: Event[]; stellateService: StellateService; injector: Injector }) { + return this.config.handler(handlerArgs); + } + constructor(private config: PurgeRuleConfig) {} +} diff --git a/packages/stellate-plugin/src/service/stellate.service.ts b/packages/stellate-plugin/src/service/stellate.service.ts new file mode 100644 index 0000000000..a3913fd265 --- /dev/null +++ b/packages/stellate-plugin/src/service/stellate.service.ts @@ -0,0 +1,150 @@ +import { Inject } from '@nestjs/common'; +import { Collection, ID, Logger, Product, ProductVariant } from '@vendure/core'; +import fetch from 'node-fetch'; + +import { loggerCtx, STELLATE_PLUGIN_OPTIONS } from '../constants'; +import { StellatePluginOptions } from '../types'; + +type CachedType = + | 'Product' + | 'ProductVariant' + | 'Collection' + | 'SearchResponse' + | 'SearchResult' + | 'SearchResponseCacheIdentifier' + | string; + +/** + * @description + * The StellateService is used to purge the Stellate cache when certain events occur. + * + * @docsCategory core plugins/StellatePlugin + */ +export class StellateService { + private readonly purgeApiUrl: string; + + constructor(@Inject(STELLATE_PLUGIN_OPTIONS) private options: StellatePluginOptions) { + this.purgeApiUrl = `https://admin.stellate.co/${options.serviceName}`; + } + + /** + * @description + * Purges the cache for the given Products. + */ + async purgeProducts(products: Product[]) { + Logger.verbose(`Purging cache: Product(${products.map(p => p.id).join(', ')})`, loggerCtx); + await this.purge( + 'Product', + products.map(p => p.id), + ); + } + + /** + * @description + * Purges the cache for the given ProductVariants. + */ + async purgeProductVariants(productVariants: ProductVariant[]) { + Logger.verbose( + `Purging cache: ProductVariant(${productVariants.map(p => p.id).join(', ')})`, + loggerCtx, + ); + await this.purge( + 'ProductVariant', + productVariants.map(p => p.id), + ); + } + + /** + * @description + * Purges the cache for SearchResults which contain the given Products or ProductVariants. + */ + async purgeSearchResults(items: Array) { + const productIds = items.map(item => (item instanceof Product ? item.id : item.productId)); + Logger.verbose(`Purging cache: SearchResult(${productIds.join(', ')})`, loggerCtx); + await this.purge('SearchResult', productIds, 'productId'); + } + + /** + * @description + * Purges the entire cache for the given type. + */ + async purgeAllOfType(type: CachedType) { + Logger.verbose(`Purging cache: All ${type}s`, loggerCtx); + await this.purge(type); + } + + /** + * @description + * Purges the cache for the given Collections. + */ + async purgeCollections(collections: Collection[]) { + Logger.verbose(`Purging cache: Collection(${collections.map(c => c.id).join(', ')})`, loggerCtx); + await this.purge( + 'Collection', + collections.map(p => p.id), + ); + } + + /** + * @description + * Purges the cache of SearchResults for the given Collections based on slug. + */ + async purgeSearchResponseCacheIdentifiers(collections: Collection[]) { + const slugs = collections.map(c => c.slug ?? c.translations?.[0]?.slug); + if (slugs.length) { + Logger.verbose(`Purging cache: SearchResponseCacheIdentifier(${slugs.join(', ')})`, loggerCtx); + await this.purge('SearchResponseCacheIdentifier', slugs); + } + } + + /** + * @description + * Purges the cache for the given type and keys. + */ + purge(type: CachedType, keys?: ID[], keyName = 'id') { + const payload = { + query: ` + mutation PurgeType($type: String!, $keyFields: [KeyFieldInput!]) { + _purgeType(type: $type, keyFields: $keyFields) + } + `, + variables: { + type, + keyFields: keys?.filter(id => !!id).map(id => ({ name: keyName, value: id.toString() })), + }, + }; + if (this.options.debugLogging === true) { + const keyFieldsLength = payload.variables.keyFields?.length ?? 0; + if (5 < keyFieldsLength) { + payload.variables.keyFields = payload.variables.keyFields?.slice(0, 5); + } + Logger.debug('Purge arguments:\n' + JSON.stringify(payload.variables, null, 2), loggerCtx); + if (5 < keyFieldsLength) { + Logger.debug(`(A further ${keyFieldsLength - 5} keyFields truncated)`, loggerCtx); + } + } + if (this.options.devMode === true) { + // no-op + } else { + return fetch(this.purgeApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'stellate-token': this.options.apiToken, + }, + body: JSON.stringify(payload), + timeout: 5000, + }) + .then(res => res.json()) + .then(json => { + if (json.data?._purgeType !== true) { + const errors = json.errors?.map((e: any) => e.message) as string[]; + Logger.error(`Purge failed: ${errors.join(', ') ?? JSON.stringify(json)}`, loggerCtx); + } + }) + .catch((err: any) => { + Logger.error(`Purge error: ${err.message as string}`, loggerCtx); + }); + } + } +} diff --git a/packages/stellate-plugin/src/stellate-plugin.ts b/packages/stellate-plugin/src/stellate-plugin.ts new file mode 100644 index 0000000000..a39cfb8700 --- /dev/null +++ b/packages/stellate-plugin/src/stellate-plugin.ts @@ -0,0 +1,288 @@ +import { Inject, OnApplicationBootstrap } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { EventBus, Injector, PluginCommonModule, VendurePlugin } from '@vendure/core'; +import { buffer, debounceTime } from 'rxjs/operators'; + +import { shopApiExtensions } from './api/api-extensions'; +import { SearchResponseFieldResolver } from './api/search-response.resolver'; +import { STELLATE_PLUGIN_OPTIONS } from './constants'; +import { StellateService } from './service/stellate.service'; +import { StellatePluginOptions } from './types'; + +const StellateOptionsProvider = { + provide: STELLATE_PLUGIN_OPTIONS, + useFactory: () => StellatePlugin.options, +}; + +/** + * @description + * A plugin to integrate the [Stellate](https://stellate.co/) GraphQL caching service with your Vendure server. + * The main purpose of this plugin is to ensure that cached data gets correctly purged in + * response to events inside Vendure. For example, changes to a Product's description should + * purge any associated record for that Product in Stellate's cache. + * + * ## Pre-requisites + * + * You will first need to [set up a free Stellate account](https://stellate.co/signup). + * + * You will also need to generate an **API token** for the Stellate Purging API. For instructions on how to generate the token, + * see the [Stellate Purging API docs](https://docs.stellate.co/docs/purging-api#authentication). + * + * ## Installation + * + * ``` + * npm install \@vendure/stellate-plugin + * ``` + * + * ## Configuration + * + * The plugin is configured via the `StellatePlugin.init()` method. This method accepts an options object + * which defines the Stellate service name and API token, as well as an array of {@link PurgeRule}s which + * define how the plugin will respond to Vendure events in order to trigger calls to the + * Stellate [Purging API](https://stellate.co/docs/graphql-edge-cache/purging-api). + * + * @example + * ```ts + * import { StellatePlugin, defaultPurgeRules } from '\@vendure/stellate-plugin'; + * import { VendureConfig } from '\@vendure/core'; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * StellatePlugin.init({ + * // The Stellate service name, i.e. `.stellate.sh` + * serviceName: 'my-service', + * // The API token for the Stellate Purging API. See the "pre-requisites" section above. + * apiToken: process.env.STELLATE_PURGE_API_TOKEN, + * debugMode: !isProd || process.env.STELLATE_DEBUG_MODE ? true : false, + * debugLogging: process.env.STELLATE_DEBUG_MODE ? true : false, + * purgeRules: [ + * ...defaultPurgeRules, + * // custom purge rules can be added here + * ], + * }), + * ], + * }; + * ``` + * + * In your Stellate dashboard, you can use the following configuration example as a sensible default for a + * Vendure application: + * + * @example + * ```ts + * import { Config } from "stellate"; + * + * const config: Config = { + * config: { + * name: "my-vendure-server", + * originUrl: "https://my-vendure-server.com/shop-api", + * ignoreOriginCacheControl: true, + * passThroughOnly: false, + * scopes: { + * SESSION_BOUND: "header:authorization|cookie:session", + * }, + * headers: { + * "access-control-expose-headers": "vendure-auth-token", + * }, + * rootTypeNames: { + * query: "Query", + * mutation: "Mutation", + * }, + * keyFields: { + * types: { + * SearchResult: ["productId"], + * SearchResponseCacheIdentifier: ["collectionSlug"], + * }, + * }, + * rules: [ + * { + * types: [ + * "Product", + * "Collection", + * "ProductVariant", + * "SearchResponse", + * ], + * maxAge: 900, + * swr: 900, + * description: "Cache Products & Collections", + * }, + * { + * types: ["Channel"], + * maxAge: 9000, + * swr: 9000, + * description: "Cache active channel", + * }, + * { + * types: ["Order", "Customer", "User"], + * maxAge: 0, + * swr: 0, + * description: "Do not cache user data", + * }, + * ], + * }, + * }; + * export default config; + * ``` + * + * ## Storefront setup + * + * In your storefront, you should point your GraphQL client to the Stellate GraphQL API endpoint, which is + * `https://.stellate.sh`. + * + * Wherever you are using the `search` query (typically in product listing & search pages), you should also add the + * `cacheIdentifier` field to the query. This will ensure that the Stellate cache is correctly purged when + * a Product or Collection is updated. + * + * @example + * ```ts + * import { graphql } from '../generated/gql'; + * + * export const searchProductsDocument = graphql(` + * query SearchProducts($input: SearchInput!) { + * search(input: $input) { + * // highlight-start + * cacheIdentifier { + * collectionSlug + * } + * // highlight-end + * items { + * # ... + * } + * } + * } + * }`); + * ``` + * + * ## Custom PurgeRules + * + * The configuration above only accounts for caching of some of the built-in Vendure entity types. If you have + * custom entity types, you may well want to add them to the Stellate cache. In this case, you'll also need a way to + * purge those entities from the cache when they are updated. This is where the {@link PurgeRule} comes in. + * + * Let's imagine that you have built a simple CMS plugin for Vendure which exposes an `Article` entity in your Shop API, and + * you have added this to your Stellate configuration: + * + * @example + * ```ts + * import { Config } from "stellate"; + * + * const config: Config = { + * config: { + * // ... + * rules: [ + * // ... + * { + * types: ["Article"], + * maxAge: 900, + * swr: 900, + * description: "Cache Articles", + * }, + * ], + * }, + * // ... + * }; + * export default config; + * ``` + * + * You can then add a custom {@link PurgeRule} to the StellatePlugin configuration: + * + * @example + * ```ts + * import { StellatePlugin, defaultPurgeRules } from "\@vendure/stellate-plugin"; + * import { VendureConfig } from "\@vendure/core"; + * import { ArticleEvent } from "./plugins/cms/events/article-event"; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * StellatePlugin.init({ + * // ... + * purgeRules: [ + * ...defaultPurgeRules, + * new PurgeRule({ + * eventType: ArticleEvent, + * handler: async ({ events, stellateService }) => { + * const articleIds = events.map((e) => e.article.id); + * stellateService.purge("Article", articleIds); + * }, + * }), + * ], + * }), + * ], + * }; + * ``` + * + * ## DevMode & Debug Logging + * + * In development, you can set `devMode: true`, which will prevent any calls being made to the Stellate Purging API. + * + * If you want to log the calls that _would_ be made to the Stellate Purge API when in devMode, you can set `debugLogging: true`. + * Note that debugLogging generates a lot of debug-level logging, so it is recommended to only enable this when needed. + * + * @example + * ```ts + * import { StellatePlugin, defaultPurgeRules } from '\@vendure/stellate-plugin'; + * import { VendureConfig } from '\@vendure/core'; + * + * export const config: VendureConfig = { + * // ... + * plugins: [ + * StellatePlugin.init({ + * // ... + * devMode: !process.env.PRODUCTION, + * debugLogging: process.env.STELLATE_DEBUG_MODE ? true : false, + * purgeRules: [ + * ...defaultPurgeRules, + * ], + * }), + * ], + * }; + * ``` + * + * + * @since 2.1.5 + * @docsCategory core plugins/StellatePlugin + */ +@VendurePlugin({ + imports: [PluginCommonModule], + providers: [StellateOptionsProvider, StellateService], + shopApiExtensions: { + schema: shopApiExtensions, + resolvers: [SearchResponseFieldResolver], + }, + compatibility: '^2.0.0', +}) +export class StellatePlugin implements OnApplicationBootstrap { + static options: StellatePluginOptions; + + static init(options: StellatePluginOptions) { + this.options = options; + return this; + } + + constructor( + @Inject(STELLATE_PLUGIN_OPTIONS) private options: StellatePluginOptions, + private eventBus: EventBus, + private stellateService: StellateService, + private moduleRef: ModuleRef, + ) {} + + onApplicationBootstrap() { + const injector = new Injector(this.moduleRef); + + for (const purgeRule of this.options.purgeRules ?? []) { + const source$ = this.eventBus.ofType(purgeRule.eventType); + source$ + .pipe( + buffer( + source$.pipe( + debounceTime(purgeRule.bufferTimeMs ?? this.options.defaultBufferTimeMs ?? 2000), + ), + ), + ) + .subscribe(events => + purgeRule.handle({ events, injector, stellateService: this.stellateService }), + ); + } + } +} diff --git a/packages/stellate-plugin/src/types.ts b/packages/stellate-plugin/src/types.ts new file mode 100644 index 0000000000..e2169ad358 --- /dev/null +++ b/packages/stellate-plugin/src/types.ts @@ -0,0 +1,52 @@ +import { PurgeRule } from './purge-rule'; + +/** + * @description + * Configuration options for the StellatePlugin. + * + * @docsCategory core plugins/StellatePlugin + */ +export interface StellatePluginOptions { + /** + * @description + * The Stellate service name, i.e. `.stellate.sh` + */ + serviceName: string; + /** + * @description + * The Stellate Purging API token. For instructions on how to generate the token, + * see the [Stellate docs](https://docs.stellate.co/docs/purging-api#authentication) + */ + apiToken: string; + /** + * @description + * An array of {@link PurgeRule} instances which are used to define how the plugin will + * respond to Vendure events in order to trigger calls to the Stellate Purging API. + */ + purgeRules: PurgeRule[]; + /** + * @description + * When events are published, the PurgeRules will buffer those events in order to efficiently + * batch requests to the Stellate Purging API. You may wish to change the default, e.g. if you are + * running in a serverless environment and cannot introduce pauses after the main request has completed. + * + * @default 2000 + */ + defaultBufferTimeMs?: number; + /** + * @description + * When set to `true`, calls will not be made to the Stellate Purge API. + * + * @default false + */ + devMode?: boolean; + /** + * @description + * If set to true, the plugin will log the calls that would be made + * to the Stellate Purge API. Note, this generates a + * lot of debug-level logging. + * + * @default false + */ + debugLogging?: boolean; +} diff --git a/packages/stellate-plugin/src/write.mjs b/packages/stellate-plugin/src/write.mjs new file mode 100644 index 0000000000..944ff0f69b --- /dev/null +++ b/packages/stellate-plugin/src/write.mjs @@ -0,0 +1,210 @@ +import fetch from 'node-fetch' +import { getIntrospectionQuery } from 'graphql' + +// TODO: you can get a token by means of using settings --> api-tokens +const token = ''; +const apiUrl = 'https://api.graphcdn.io/api' +const originUrl = 'https://trygql.formidable.dev/graphql/basic-pokedex' + +// The app { config { input } } can be used to write the graphcdn.YAML file with help of libs like "@atomist/yaml-updater" +async function listOrganizations() { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'graphcdn-token': token, + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query { + user { + organizations { + id + name + slug + } + } + } + `, + }) + }) + + const result = await response.json() + + return result.data.user.organizations +} + +async function pushAppConfig(orgId, schema) { + const config = { + name: 'node-write-test', + originUrl, + schema: originUrl, + queryDepthLimit: 20, + ignoreOriginCacheControl: true, + enablePlayground: true, + injectHeaders: true, + headers: { + 'something-to-inject': '1', + }, + keyFields: { + types: { + Pokemon: ['id', 'name'] + } + }, + scopes: { + AUTHENTICATED: 'header:Authorization', + }, + rootTypeNames: { query: 'Query' }, + rules: [ + { description: 'Cache all queries', maxAge: 600, swr: 900, scope: 'AUTHENTICATED', types: ['Query'] }, + ], + bypassCacheHeaders: [{ name: 'x-preview-token' }], + } + + const result = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'graphcdn-token': token, + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + mutation ( + $input: Input! + $appName: String! + $schema: IntrospectionQuery + ) { + pushAppConfig( + input: $input + appName: $appName + schema: $schema + allowDeletion: true + ) + } + `, + variables: { + input: config, + schema: schema, + appName: config.name, + } + }) + }) +} + +async function createApp(orgId, schema) { + const config = { + name: 'node-write-test', + originUrl, + schema: originUrl, + queryDepthLimit: 20, + ignoreOriginCacheControl: true, + enablePlayground: true, + injectHeaders: true, + headers: { + 'something-to-inject': '1', + }, + keyFields: { + types: { + Pokemon: ['id', 'name'] + } + }, + scopes: { + AUTHENTICATED: 'header:Authorization', + }, + rootTypeNames: { query: 'Query' }, + rules: [ + { description: 'Cache all queries', maxAge: 600, swr: 900, scope: 'AUTHENTICATED', types: ['Query'] }, + ], + bypassCacheHeaders: [{ name: 'x-preview-token' }], + } + + const result = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'graphcdn-token': token, + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + mutation ( + $input: Input! + $schema: IntrospectionQuery + $organizationId: String! + ) { + createAppCli( + input: $input + schema: $schema + organizationId: $organizationId + ) { + id + config { + input + } + } + } + `, + variables: { + input: config, + schema: schema, + organizationId: orgId + } + }) + }) + + return await result.json() +} + +async function getServices(slug) { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'graphcdn-token': token, + }, + body: JSON.stringify({ + query: /* GraphQL */` + query ($slug: String!) { + organization(slug: $slug) { + name + apps { + name + updatedAt + config { + input + } + } + } + } + `, + variables: { slug } + }) + }) + + const result = await response.json() + + return result.data.organization.apps +} + +async function main() { + const introspectionQuery = getIntrospectionQuery() + + const introspectionResponse = await fetch(originUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: introspectionQuery, + }) + }) + + const { data: schema } = await introspectionResponse.json() + + const organizations = await listOrganizations(); + + console.log(organizations) + const result = await createApp(organizations[0].id, schema) + console.log(result) +} + +main() diff --git a/packages/stellate-plugin/tsconfig.build.json b/packages/stellate-plugin/tsconfig.build.json new file mode 100644 index 0000000000..821b637ae7 --- /dev/null +++ b/packages/stellate-plugin/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./lib" + }, + "files": [ + "./index.ts" + ] +} diff --git a/packages/stellate-plugin/tsconfig.json b/packages/stellate-plugin/tsconfig.json new file mode 100644 index 0000000000..f343c8c4b3 --- /dev/null +++ b/packages/stellate-plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "removeComments": false, + "noLib": false, + "skipLibCheck": true, + "sourceMap": true + } +} diff --git a/scripts/changelogs/generate-changelog.ts b/scripts/changelogs/generate-changelog.ts index 5c5e0b901d..a257d31230 100644 --- a/scripts/changelogs/generate-changelog.ts +++ b/scripts/changelogs/generate-changelog.ts @@ -35,6 +35,7 @@ const VALID_SCOPES: string[] = [ 'testing', 'ui-devkit', 'harden-plugin', + 'stellate-plugin', ]; const mainTemplate = fs.readFileSync(path.join(__dirname, 'template.hbs'), 'utf-8'); diff --git a/scripts/docs/generate-typescript-docs.ts b/scripts/docs/generate-typescript-docs.ts index 071fb04313..2de072e5c4 100644 --- a/scripts/docs/generate-typescript-docs.ts +++ b/scripts/docs/generate-typescript-docs.ts @@ -49,6 +49,10 @@ const sections: DocsSectionConfig[] = [ sourceDirs: ['packages/harden-plugin/src/'], outputPath: '', }, + { + sourceDirs: ['packages/stellate-plugin/src/'], + outputPath: '', + }, { sourceDirs: ['packages/admin-ui/src/lib/', 'packages/ui-devkit/src/'], exclude: [/generated-types/],