Releases: boostercloud/booster
Sequenced Read Models and improved Read Model Before Hooks.
This release introduces two features:
Sequenced Read Models
This release introduces the ability to model read models as time sequenced (Or time series). This feature is useful to model a series of data objects that are indexed by the time they were logged. This might sound similar to events, but sequenced read models are also mutable, so they can change over time without losing their place in the sequence. An example use case could be a chat application, where all messages are identified by a specific channel ID and a timestamp of when they were sent, but individual messages can be edited or deleted.
Booster provides a special decorator to tag a specific property as a sequence key for a read model:
export class MessageReadModel {
public constructor(
readonly id: UUID, // A channel ID
@sequencedBy readonly timestamp: string,
readonly contents: string
)
@Projects(Message, 'id')
public static projectMessage(entity: Message, currentReadModel: MessageReadModel): ProjectionResult<MessageReadModel> {
return new MessageReadModel(entity.id, entity.timestamp, entity.contents)
}
}
Adding a sequence key to a read model changes the behavior of its query, which now accepts the sequence key as an optional parameter:
query MessageReadModel(id: ID!, timestamp: string): [MessageReadModel]
In this query, when only the id
is provided, you get an array of all the messages in the channel ordered by timestamp in ascending order (from older to newer). When you also provide a specific timestam
, you still get an array, but it will only contain the specific message sent at that time.
You can read more about sequenced read models in the documentation.
Improved Read Model Before Hooks
Read Model Before Hooks allow you to set up one or more functions that are called before a read model request is processed, having the opportunity of altering or canceling the request. A common use case for read model before hooks is managing complex access restrictions policies that depend on the business logic, like allowing a user to access objects they own but not objects they should not have access to.
Starting from this release, before hooks will receive the entire request object received from GraphQL, and not only the filters, opening the door to more complex rewrites of specific queries. Before hook functions will receive and return ReadModelRequestEnvelope
objects which have the following signature:
interface ReadModelRequestEnvelope<TReadModel> {
currentUser?: UserEnvelope // The current authenticated user
requestID: UUID // An ID assigned to this request
key?: { // If present, contains the id and sequenceKey that identify a specific read model
id: UUID
sequenceKey?: SequenceKey
}
className: string // The read model class name
filters: ReadModelRequestProperties<TReadModel> // Filters set in the GraphQL query
limit?: number // Query limit if set
afterCursor?: unknown // For paginated requests, id to start reading from
}
Fixes in the GraphQL input types
In the latest releases, the GraphQL schema generation was improved by adding GraphQLNonNull
to elements of arrays.
However, this caused arrays in input types to result in JSONObject
instead of the specific type. (e9906b4)
What happened in the last release
Here's what happened
It also broke PR #890 which also fixes some JSONObject
types, returning specific types instead.
[Booster] Error: The type of UpsertDossierInput.parties must be Input Type but got: [DossierParty!].
With this release the input types are improved again, so they behave as expected:
Command handlers that return values
In this release we introduce the @Returns
decorator that allows you to define the return type of a command handler.
This value will be returned to the client through GraphQL, instead of just returning true
(that's left for the case of void
).
As an example:
@Command({
authorize: 'all',
})
export class CreateProduct {
public constructor(readonly sku: string, readonly price: number) {}
@Returns(String)
public static async handle(command: CreateProduct, register: Register): Promise<string> {
return "Product created!"
}
}
Learn more in the Booster documentation
Introducing GraphQL Pagination on queries
From now on the Booster GraphQL API includes a type for your read models that stands for List<YourReadModelName>
, which is the official way to work with pagination. Alternatively, and for compatibility with previous versions, there is still the type without the List
prefix, which will be deprecated in future versions.
The new Read Model List type includes some new parameters that can be used on queries:
limit
; an integer that specifies the maximum number of items to be returned.afterCursor
; a parameter to set thecursor
property returned by the previous query, if not null.
Example:
query {
ListProductReadModels
(
limit: 1,
afterCursor: { id: "last-page-item"}
) {
id
sku
availability
price
}
}
Besides the parameters, this type also returns a type {your-read-model-name}Connection
, it includes the following properties:
cursor
; if there are more results to paginate, it will return the object to pass to theafterCursor
parameter on the next query. If there aren't more items to be shown, it will be undefined.items
; the list of items returned by the query, if there aren't any, it will be an empty list.
Local provider enabling and lazy provider loading
From now on, setting a provider is done by specifying config.providerPackage
, rather than the old config.provider
.
In this new way, instead of import
ing the Provider object and assigning it in the config.provider
field, you simply specify the name of the package as a string:
// Old way – Now deprecated
import * as AWS from '@boostercloud/framework-provider-aws'
Booster.configure(environment.name, (config: BoosterConfig) => {
config.appName = 'my-cool-app'
config.provider = AWSProvider()
})
// New way
Booster.configure(environment.name, (config: BoosterConfig) => {
config.appName = 'my-cool-app'
config.providerPackage = '@boostercloud/framework-provider-aws'
// New optional field for specifying rockets
config.rockets = [/* your rockets here */]
})
With this addition, now Booster provider libraries are loaded on runtime when they are needed, meaning that if you want to deploy the same application to different providers (e.g. AWS, and Azure) you won't get any runtime errors complaining that the SDK for the cloud provider is missing.
Commands' before hooks
This time we're adding before
hooks to command handlers! They work the same as the Read Models ones, except that you can modify inputs
instead of filters
.
Here's an example in case you're curious, where we just check if the cartUserId
is equal to the currentUser.id
, which is the user id extracted from the auth token:
@Command({
authorize: [User],
before: [beforeFn],
})
export class ChangeCartItem {
public constructor(readonly cartId: UUID, readonly productId: UUID, readonly quantity: number) {
}
}
function beforeFn(input: CommandInput, currentUser?: UserEnvelope): CommandInput {
if (input.cartUserId !== currentUser.id) {
throw NonAuthorizedUserException() // We don't let this user to trigger the command
}
return input
}
This way, we can throw an exception and avoid this user calling this command.
Bug fix: Create classes instances of entities, read models and events in the Booster class
🐞 Bug Fixes
Until now, when you get entities, read models and events using the Booster class, you get a raw javascript object instead of an instance of their respective classes.
To fix this bug, a proper instance has been created for entities, read models and events of the Booster class.
For example, in the case of entities, the original code called the deprecated fetchEntitySnapshot
method:
public static entity<TEntity extends EntityInterface>(
entityClass: Class<TEntity>,
entityID: UUID
): Promise<TEntity | undefined> {
return fetchEntitySnapshot(this.config, this.logger, entityClass, entityID)
}
And now, the deprecated fetchEntitySnapshot
method has been removed and we call a new method called createInstance
to fix it.
public static async entity<TEntity extends EntityInterface>(
entityClass: Class<TEntity>,
entityID: UUID
): Promise<TEntity | undefined> {
const eventStore = new EventStore(this.config, this.logger)
const entitySnapshotEnvelope = await eventStore.fetchEntitySnapshot(entityClass.name, entityID)
return entitySnapshotEnvelope ? createInstance(entityClass, entitySnapshotEnvelope) : undefined
}
export function createInstance<T>(instanceClass: Class<T>, rawObject: Record<string, any>): T {
const instance = new instanceClass()
Object.assign(instance, rawObject)
return instance
}
Read models' before hooks
With this new version, we can now add before
hooks to our @ReadModel annotations. This can become really handy to either:
- Validate parameters
- Change read model filters on the fly with programmatic logic
- Deny access to specific users
For example, we could deny user's access when he/she's not the Cart's owner:
@ReadModel({
authorize: [User],
before: [validateUser],
})
export class CartReadModel {
public constructor(
readonly id: UUID,
readonly userId: UUID
) {}
// Your projections go here
}
function validateUser(filter: FilterFor<CartReadModel>, currentUser?: UserEnvelope): FilterFor<CartReadModel> {
if (filter?.userId !== currentUser.id) throw NotAuthorizedError("...")
return filter
}
You can also chain these before functions to split your logic:
import { changeFilters } from '../../filters-helper' // You can also use external functions!
@ReadModel({
authorize: [User],
before: [validateUser, validateEmail, changeFilters],
})
export class CartReadModel {
public constructor(
readonly id: UUID,
readonly userId: UUID
) {}
// Your projections go here
}
function validateUser(filter: FilterFor<CartReadModel>, currentUser?: UserEnvelope): FilterFor<CartReadModel> {
if (filter.userId !== currentUser.id) throw NotAuthorizedError("...")
return filter // This filter will be passed as a parameter to the validateEmail function
}
function validateEmail(filter: FilterFor<CartReadModel>, currentUser?: UserEnvelope): FilterFor<CartReadModel> {
if (!filter.email.includes('myCompanyDomain.com')) throw NotAuthorizedError("...")
return filter
}
As a side note, remember that the order in which filters are specified matters.
New GraphQL Schema and complex filters
Finally, one of the most requested features is here! Complex filters on the GraphQL API. This change will allow users to filter read models like never before, for instance:
query {
ProductReadModels(filter: { price: { gt: 200 } }) {
id
sku
availability
price
}
}
Now you can filter by specific types, complex properties, and even use filter combinators! You can find the complete set of filters in the documentation.
⚠ Breaking Changes
To perform these filters, the GraphQL schema has been redesigned, which introduces breaking changes for existing Booster applications with versions lower than v0.16.0
.
Those are the required steps to migrate your app to use Booster v0.16.0
or later successfully.
Update package.json
file.
Include the following dependencies to your package.json
file on the devDependencies
section.
"ttypescript": "1.5.12",
"metadata-booster": "0.3.1"
Also, update the TypeScript dependency version to 4.1.5
a ts-node
to 9.1.1
.
"typescript": "4.1.5",
"ts-node": "9.1.1",
Finally, change the compile script
to use ttsc
without npx
instead of using npx tsc
, it should look like this:
"compile": "ttsc -b tsconfig.json",
Update tsconfig.json
file.
Remove the "emitDecoratorMetadata": true
line from the tsconfig.json
file, and add the following property.
"plugins": [
{ "transform": "metadata-booster" }
]
This property allows the application to use the metadata-booster
plugin when compiling TypeScript code, allowing the compiled JavaScript files to know the properties of every type created. Ensure you have the following properties:
"experimentalDecorators": true,
"plugins": [
{ "transform": "metadata-booster" }
]
Note: remember to delete the emitDecoratorMetadata
property to avoid getting compilation errors.
Upgrade npm
To be able to run the npm
scripts properly, especially the compilation one, you will need to upgrade at least to npm v7
or later.
And that's it! 🎉 Your Booster application is ready to upgrade and use the new GraphQL API filters! 🚀
API for reading events and huge reliability improvements on high loads
It's been a lot going on in Booster lately so expect some very exciting news listed here!
⚠ Breaking Changes
- In version 0.12.0 there was a change in the AWS Provider event store (DynamoDB) to create two new indexes. If you are updating from version 0.11.* or older, you will get an error when deploying your app because AWS limits the number of new indexes per deployment to just one. The solution is simple but involves doing two deploys. Follow these steps:
- Update all your Booster dependencies (core, types, etc) to this new version, "0.14.4", except the
framework-provider-aws-infrastructure
package. - For that package, use the following version: "0.14.4-pre12step.1". This version is exactly the same as the "0.14.4" but it only creates one index. The
package.json
file should show something similar to this:
- Deploy your application with
boost deploy
. You should not see any error now. - When finished, change the version of
framework-provider-aws-infrastructure
to the final one, "0.14.4". Thepackage.json
file should now show something like this:
- Do another deployment and you are done.
- Update all your Booster dependencies (core, types, etc) to this new version, "0.14.4", except the
⚰️ Deprecated
- Method
Booster.fetchEntitySnapshot
is deprecated and will be deleted in the next major version. UseBooster.entity
instead.
🚀 Features & Improvements
- Booster now generates a GraphQL API to directly read events (besides your read models). Documentation is being created (issue #790), but you can take a look at this PR description to know what is it about
- You can also query events in your command or event handlers by using the
Booster.events
method - The storage and processing of events is now much more reliable on extremely high loads (thousands of events per second)
- Added experimental support for Azure provider 🎉
- Added experimental support for Kubernetes provider 🎉
🩹 Fixes
- Fixed a bug caused by a race condition on huge loads when projecting read models. Now optimistic concurrency is used to ensure data is not corrupted.
- Fixed a bug that was causing some events to be overwritten on huge loads.
- Many, many, many more bug fixes
🐛 Known bug
- We are working to provide full support to filter read models by any property. Some of those filters, although not documented, were available using a "beta" structure. If you were one of those adventurous users that were using them, you need to know that they are broken in this version. In the next version we will release full support for those filters, so stay tuned!