データ移行パターン
このガイドでは、テナント間データ移行、スキーマ進化、インポートモジュールを使用した一括データ操作、ロールバック手順など、MBC CQRS Serverlessアプリケーションにおけるデータ移行戦略について説明します。
このガイドを使用するタイミング
以下の場合にこのガイドを使用してください:
- テナント間でデータを移行する
- 互換性を維持しながらデータスキーマを進化させる
- インポートモジュールで一括データ操作を実行する
- 失敗した移行のロールバック手順を実装する
- 移行プロセス中にデータを検証する
移行アーキテクチャの概要
┌─────────────────────────────────────────────────────────────────────────┐
│ データ移行フロー │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ ソース │────>│ 変換 │────>│ ターゲット │ │
│ │ データ │ │ & 検証 │ │ データ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │ インポート │ │ │
│ │ │ モジュール │ │ │
│ └─────────>│ ストラテジー │<─────────────┘ │
│ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ CommandSvc│────>│ DynamoDB │ │
│ │ (バージョン) │ │ (履歴) │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
テナントコード正規化マイグレーション
破壊的変更
getUserContext()関数はtenantCodeを小文字に正規化します。既 存のデータがパーティションキーに大文字のテナントコードを使用している場合、これはデータアクセスに影響する破壊的変更です。
完全なマイグレーションチェックリストとステップバイステップの手順については、v1.1.0 マイグレーションガイドを参照してください。
影響の理解
フレームワークは大文字小文字を区別しないマッチングのためにテナントコードを小文字に正規化します:
// Before: Cognito stores uppercase (変更前:Cognitoには大文字で保存)
custom:tenant = "MY_TENANT"
// After: getUserContext() returns lowercase (変更後:getUserContext()は小文字を返す)
const userContext = getUserContext(ctx);
console.log(userContext.tenantCode); // "my_tenant"
// Partition key generation uses lowercase (パーティションキー生成は小文字を使用)
const pk = `PRODUCT#${userContext.tenantCode}`; // "PRODUCT#my_tenant"
問題: 既存のDynamoDBデータに大文字のテナントコードを持つパーティションキー(例:PRODUCT#MY_TENANT)がある場合、正規化された小文字のテナントコードを使用したクエリではそのデータを見つけることができません。
マイグレーション戦略1:DynamoDBデータの更新
既存のDynamoDBデータを移行して、パーティションキーで小文字のテナントコードを使用するよ うにします。
// migration/tenant-normalization-migration.service.ts
import { Injectable, Logger } from '@nestjs/common';
import {
DynamoDbService,
CommandService,
KEY_SEPARATOR,
generateId,
getTenantCode,
IInvoke,
} from '@mbc-cqrs-serverless/core';
@Injectable()
export class TenantNormalizationMigrationService {
private readonly logger = new Logger(TenantNormalizationMigrationService.name);
constructor(
private readonly dynamoDbService: DynamoDbService,
private readonly commandService: CommandService,
) {}
/**
* Migrate all entities of a type to lowercase tenant codes (タイプのすべてのエンティティを小文字のテナントコードに移行)
*/
async migrateEntityType(
tableName: string,
entityPrefix: string,
invokeContext: IInvoke,
): Promise<{ migrated: number; errors: string[] }> {
let migrated = 0;
const errors: string[] = [];
// Scan for items with uppercase tenant codes (大文字のテナントコードを持つアイテムをスキャン)
const items = await this.scanForUppercaseTenants(tableName, entityPrefix);
for (const item of items) {
try {
const oldTenantCode = getTenantCode(item.pk);
const newTenantCode = oldTenantCode?.toLowerCase();
if (!newTenantCode || oldTenantCode === newTenantCode) {
continue; // Already lowercase or no tenant code (すでに小文字またはテナントコードなし)
}
// Create new record with lowercase tenant code (小文字のテナントコードで新しいレコードを作成)
const newPk = `${entityPrefix}${KEY_SEPARATOR}${newTenantCode}`;
const newId = generateId(newPk, item.sk);
await this.commandService.publishSync({
pk: newPk,
sk: item.sk,
id: newId,
tenantCode: newTenantCode,
code: item.code,
name: item.name,
type: item.type,
version: 0, // New entity (新しいエンティティ)
attributes: {
...item.attributes,
_migratedFrom: item.id,
_migrationReason: 'tenant_code_normalization',
_migratedAt: new Date().toISOString(),
},
}, { invokeContext });
// Mark old record as migrated (soft delete) (古いレコードを移行済みとしてマーク(ソフト削除))
await this.commandService.publishPartialUpdateSync({
pk: item.pk,
sk: item.sk,
version: item.version,
isDeleted: true,
attributes: {
...item.attributes,
_migratedTo: newId,
},
}, { invokeContext });
migrated++;
this.logger.log(`Migrated ${item.id} to ${newId}`);
} catch (error) {
errors.push(`Failed to migrate ${item.id}: ${error.message}`);
this.logger.error(`Migration error for ${item.id}`, error);
}
}
return { migrated, errors };
}
private async scanForUppercaseTenants(
tableName: string,
entityPrefix: string,
): Promise<any[]> {
// Implement scanning logic to find items with uppercase tenant codes (大文字のテナントコードを持つアイテムを見つけるスキャンロジックを実装)
// Filter for PKs that start with entityPrefix and contain uppercase letters (entityPrefixで始まり大文字を含むPKをフィルタリング)
return [];
}
}
マイグレーション戦略2:Cognitoユーザー属性の更新
Cognitoユーザー属性を小文字のテナントコードを使用するように更新します。このアプローチはデータ移行を回避しますが、Cognito管理者アクセスが必要です。
// migration/cognito-tenant-migration.service.ts
import { Injectable, Logger } from '@nestjs/common';
import {
CognitoIdentityProviderClient,
ListUsersCommand,
AdminUpdateUserAttributesCommand,
} from '@aws-sdk/client-cognito-identity-provider';
@Injectable()
export class CognitoTenantMigrationService {
private readonly logger = new Logger(CognitoTenantMigrationService.name);
private readonly cognitoClient: CognitoIdentityProviderClient;
constructor() {
this.cognitoClient = new CognitoIdentityProviderClient({});
}
/**
* Migrate all users' custom:tenant to lowercase (すべてのユーザーのcustom:tenantを小文字に移行)
*/
async migrateAllUsers(userPoolId: string): Promise<{
migrated: number;
errors: string[];
}> {
let migrated = 0;
const errors: string[] = [];
let paginationToken: string | undefined;
do {
const listResponse = await this.cognitoClient.send(
new ListUsersCommand({
UserPoolId: userPoolId,
PaginationToken: paginationToken,
}),
);
for (const user of listResponse.Users ?? []) {
try {
const tenantAttr = user.Attributes?.find(
(a) => a.Name === 'custom:tenant',
);
const rolesAttr = user.Attributes?.find(
(a) => a.Name === 'custom:roles',
);
if (!tenantAttr?.Value) continue;
const oldTenant = tenantAttr.Value;
const newTenant = oldTenant.toLowerCase();
if (oldTenant === newTenant) continue; // Already lowercase (すでに小文字)
// Update tenant attribute (テナント属性を更新)
const attributes = [
{ Name: 'custom:tenant', Value: newTenant },
];
// Update roles if they contain tenant references (テナント参照が含まれている場合はロールを更新)
if (rolesAttr?.Value) {
const roles = JSON.parse(rolesAttr.Value);
const updatedRoles = roles.map((r: any) => ({
...r,
tenant: (r.tenant || '').toLowerCase(),
}));
attributes.push({
Name: 'custom:roles',
Value: JSON.stringify(updatedRoles),
});
}
await this.cognitoClient.send(
new AdminUpdateUserAttributesCommand({
UserPoolId: userPoolId,
Username: user.Username,
UserAttributes: attributes,
}),
);
migrated++;
this.logger.log(`Migrated user ${user.Username}`);
} catch (error) {
errors.push(`Failed to migrate ${user.Username}: ${error.message}`);
}
}
paginationToken = listResponse.PaginationToken;
} while (paginationToken);
return { migrated, errors };
}
}