Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ReadModel migrateAll method #1393

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@boostercloud/framework-core",
"comment": "Add migrateAll ReadModels",
"type": "minor"
}
],
"packageName": "@boostercloud/framework-core"
}
161 changes: 77 additions & 84 deletions common/config/rush/pnpm-lock.yaml

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions packages/framework-core/src/read-model-schema-migrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,37 @@ import {
InvalidVersionError,
SchemaMigrationMetadata,
ReadModelInterface,
FilterFor,
ReadModelListResult,
} from '@boostercloud/framework-types'
import { getLogger } from '@boostercloud/framework-common-helpers'

export class ReadModelSchemaMigrator {
private static readonly LIMIT = 100

public constructor(private config: BoosterConfig) {}

public async migrateAll(readModelName: string, batchSize = ReadModelSchemaMigrator.LIMIT): Promise<number> {
const filterFor = this.buildFilterForSearchReadModelsToMigrate(readModelName)
let cursor: Record<'id', string> | undefined = undefined
let total = 0
do {
const toMigrate: ReadModelListResult<ReadModelInterface> = await this.searchReadModelsToMigrate(
readModelName,
filterFor,
batchSize,
cursor
)
cursor = toMigrate.items.length >= batchSize ? toMigrate.cursor : undefined
const migrationPromises = toMigrate.items.map((item) => this.applyAllMigrations(item, readModelName))
const migratedReadModels = await Promise.all(migrationPromises)
const persistPromises = migratedReadModels.map((readModel) => this.persistReadModel(readModel, readModelName))
await Promise.all(persistPromises)
total += toMigrate.count ?? 0
} while (cursor)
return total
}

public async migrate<TMigratableReadModel extends ReadModelInterface>(
readModel: TMigratableReadModel,
readModelName: string
Expand Down Expand Up @@ -95,4 +120,60 @@ export class ReadModelSchemaMigrator {
private static readModelSchemaVersion(readModel: ReadModelInterface): number {
return readModel.boosterMetadata?.schemaVersion ?? 1
}

private buildFilterForSearchReadModelsToMigrate(readModelName: string): FilterFor<ReadModelInterface> {
const expectedVersion = this.config.currentVersionFor(readModelName)
return {
or: [
{
boosterMetadata: {
schemaVersion: {
lt: expectedVersion,
},
},
},
{
boosterMetadata: {
schemaVersion: {
isDefined: false,
},
},
},
],
}
}

private async searchReadModelsToMigrate(
readModelName: string,
filterFor: FilterFor<ReadModelInterface>,
limit: number,
cursor: undefined | Record<'id', string>
): Promise<ReadModelListResult<ReadModelInterface>> {
return (await this.config.provider.readModels.search<ReadModelInterface>(
this.config,
readModelName,
filterFor,
{},
limit,
cursor,
true
)) as ReadModelListResult<ReadModelInterface>
}

private persistReadModel(newReadModel: ReadModelInterface, readModelName: string): Promise<unknown> {
const logger = getLogger(this.config, 'ReadModelSchemaMigrator#persistReadModel')
if (!(newReadModel && newReadModel.boosterMetadata)) {
throw new Error(`Error migrating ReadModel: ${newReadModel}`)
}
const currentReadModelVersion: number = newReadModel?.boosterMetadata?.version ?? 0
const schemaVersion: number =
newReadModel?.boosterMetadata?.schemaVersion ?? this.config.currentVersionFor(readModelName)
newReadModel.boosterMetadata = {
...newReadModel?.boosterMetadata,
version: currentReadModelVersion + 1,
schemaVersion: schemaVersion,
}
logger.debug('Storing new version of read model', newReadModel)
return this.config.provider.readModels.store(this.config, readModelName, newReadModel, currentReadModelVersion)
}
}
41 changes: 38 additions & 3 deletions packages/framework-core/test/read-model-schema-migrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
/* eslint-disable @typescript-eslint/no-magic-numbers */

import { expect } from './expect'
import { BoosterConfig, SchemaMigrationMetadata, ReadModelInterface, UUID } from '@boostercloud/framework-types'
import { BoosterConfig, ReadModelInterface, SchemaMigrationMetadata, UUID } from '@boostercloud/framework-types'
import { ReadModelSchemaMigrator } from '../src/read-model-schema-migrator'
import 'mocha'
import { fake, replaceGetter } from 'sinon'

class TestConceptV1 {
public constructor(readonly id: UUID, readonly field1: string) {}
Expand Down Expand Up @@ -44,10 +46,11 @@ describe('ReadModelSchemaMigrator', () => {
toVersion: 3,
})
const config = new BoosterConfig('test')
config.schemaMigrations['TestConcept'] = migrations
const migrator = new ReadModelSchemaMigrator(config)

describe('migrate', async () => {
config.schemaMigrations['TestConcept'] = migrations
const migrator = new ReadModelSchemaMigrator(config)

it('throws when the schemaVersion of the concept to migrate is lower than 1', async () => {
const toMigrate: ReadModelInterface = {
id: 'id',
Expand Down Expand Up @@ -114,4 +117,36 @@ describe('ReadModelSchemaMigrator', () => {
expect(got).to.be.deep.equal(expected)
})
})

describe('migrateAll', () => {
it('returns 0 when there are not ReadModels with schemaVersion less than expected', async () => {
const config = new BoosterConfig('test')
replaceGetter(config, 'provider', () => {
return {
readModels: {
search: fake.returns({ items: [] }),
},
} as any
})
const migrator = new ReadModelSchemaMigrator(config)
const number = await migrator.migrateAll('TestConcept')
expect(number).to.be.deep.equal(0)
})

it('returns ReadModels with schemaVersion less than expected', async () => {
const config = new BoosterConfig('test')
replaceGetter(config, 'provider', () => {
return {
readModels: {
search: fake.returns({ items: [{ id: 1 }, { id: 2 }, { id: 3 }], count: 3, cursor: { id: 10 } }),
store: fake.resolves(''),
},
} as any
})
config.schemaMigrations['TestConcept'] = migrations
const migrator = new ReadModelSchemaMigrator(config)
const number = await migrator.migrateAll('TestConcept')
expect(number).to.be.deep.equal(3)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Booster, Command } from '@boostercloud/framework-core'
import { Register } from '@boostercloud/framework-types'
import { ReadModelSchemaMigrator } from '@boostercloud/framework-core/dist/read-model-schema-migrator'

@Command({
authorize: 'all',
})
export class MigrateAllReadModel {
public constructor(readonly readModelName: string) {}

public static async handle(command: MigrateAllReadModel, register: Register): Promise<string> {
const readModelSchemaMigrator = new ReadModelSchemaMigrator(Booster.config)
const result = await readModelSchemaMigrator.migrateAll(command.readModelName)
return `Migrated ${result} ${command.readModelName}`
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { CartReadModelV1, CartReadModelV2 } from './schema-versions'
import { SchemaMigration, ToVersion } from '@boostercloud/framework-core'
import { CartReadModel } from '../../../read-models/cart-read-model'

@SchemaMigration(CartReadModel)
export class CartReadModelMigration {
@ToVersion(2, { fromSchema: CartReadModelV1, toSchema: CartReadModelV2 })
public async splitDescriptionFieldIntoShortAndLong(old: CartReadModelV1): Promise<CartReadModelV2> {
return new CartReadModelV2(old.id, old.cartItems, 0, old.shippingAddress, old.payment, old.cartItemsIds)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { UUID } from '@boostercloud/framework-types'
import { CartReadModel } from '../../../read-models/cart-read-model'
import { CartItem } from '../../../common/cart-item'
import { Address } from '../../../common/address'
import { Payment } from '../../../entities/payment'

export class CartReadModelV1 {
public constructor(
readonly id: UUID,
readonly cartItems: Array<CartItem>,
public shippingAddress?: Address,
public payment?: Payment,
public cartItemsIds?: Array<string>
) {}
}

// Current version
export class CartReadModelV2 extends CartReadModel {}
31 changes: 31 additions & 0 deletions website/docs/10_going-deeper/data-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,37 @@ export class ProductMigration {

In this example, the `changeNameFieldToDisplayName` function updates the `Product` entity from version 1 to version 2 by renaming the `name` field to `displayName`. Then, `addNewField` function updates the `Product` entity from version 2 to version 3 by adding a new field called `newField` to the entity's schema. Notice that at this point, your database could have snapshots set as v1, v2, or v3, so while it might be tempting to redefine the original migration to keep a single 1-to-3 migration, it's usually a good idea to keep the intermediate steps. This way Booster will be able to handle any scenario.

### Migrating Multiple ReadModels at Once with "migrateAll"

ReadModelSchemaMigrator includes the method "migrateAll" that enables you to migrate all ReadModels of a specific type in a single step.

To use the "migrateAll" method, simply pass the name of the ReadModel type you wish to migrate. The system will automatically migrate all ReadModels of that type that have a version less than the expected one.

In addition, the "migrateAll" method of Booster persists the changes made to the database. This ensures that the changes in the structure of the ReadModels are permanently stored in the database.

Furthermore, the "migrateAll" method also allows you to specify a second parameter that defines the size of the blocks in which the ReadModels are processed. If a number is passed, for example 10, the ReadModels will be read, migrated, and saved in blocks of 10.

Example:

```typescript
import { Booster, Command } from '@boostercloud/framework-core'
import { Register } from '@boostercloud/framework-types'
import { ReadModelSchemaMigrator } from '@boostercloud/framework-core/dist/read-model-schema-migrator'

@Command({
authorize: 'all',
})
export class MigrateAllReadModel {
public constructor(readonly readModelName: string) {}

public static async handle(command: MigrateAllReadModel, register: Register): Promise<string> {
const readModelSchemaMigrator = new ReadModelSchemaMigrator(Booster.config)
const result = await readModelSchemaMigrator.migrateAll(command.readModelName)
return `Migrated ${result} ${command.readModelName}`
}
}
```

## Data migrations

Data migrations can be seen as background processes that can actively update the values of existing entities and read models in the database. They can be useful to perform data migrations that cannot be handled with schema migrations, for example when you need to update the values exposed by the GraphQL API, or to initialize new read models that are projections of previously existing entities.
Expand Down