Claude Code Plugins

Community-maintained marketplace

Feedback

Guide version migrations for MBC CQRS Serverless framework. Use this when upgrading framework versions, migrating from deprecated APIs, or understanding breaking changes between versions.

Install Skill

Shared

Installs to .agents/skills, used by Codex, Amp, Warp, Cursor, OpenCode, and more.

CodexAmp
Warp
CursorOpenCode
Cline
Gemini CLI
GitHub Copilot
Personal

Available across projects.

$npx skills-installer add @mbc-net/mbc-cqrs-serverless/mbc-migrate --client shared
Project

Writes to .agents/skills.

$npx skills-installer add @mbc-net/mbc-cqrs-serverless/mbc-migrate -p --client shared
Note: Review the skill instructions before using it.

SKILL.md

name mbc-migrate
description Guide version migrations for MBC CQRS Serverless framework. Use this when upgrading framework versions, migrating from deprecated APIs, or understanding breaking changes between versions.

Pre-flight Check (Version Update)

Before executing this skill, check for updates:

  1. Run mbc install-skills --check to check if a newer version is available
  2. If the output shows "Update available: X.Y.Z → A.B.C", ask the user:
    • "A newer version of MBC skills is available (X.Y.Z → A.B.C). Would you like to update before proceeding?"
  3. If the user agrees, run mbc install-skills --force to update
  4. If the user declines or skills are up-to-date, proceed with the skill

Note: Skip this check if the user explicitly says to skip updates or if you've already checked in this session.


MBC CQRS Serverless Migration Guide

This skill helps migrate MBC CQRS Serverless projects between versions.

Version Migration Matrix

From Version To Version Migration Complexity Key Changes
v1.0.16 v1.0.17 Low MasterDataService.search() fix
v1.0.17 v1.0.18 Low ImportStatusHandler.sendTaskFailure()
v1.0.18 v1.0.19 Low ImportQueueEventHandler error handling
v1.0.19 v1.0.20 Low CsvImportSfnEventHandler status fix
v1.0.20 v1.0.21 Medium ZIP Finalization Hooks
v1.0.21 v1.0.22 Low DynamoDB export filter fix
v1.0.22 v1.0.23 Low sendInlineTemplateEmail()
v1.0.x v1.1.0 High TENANT_COMMON → lowercase; publish() removed
v1.1.x v1.1.4 Low publishSync audit trail (transparent)
v1.1.x v1.1.5 Medium CSV import v2 batch architecture
v1.1.x v1.2.0 High publishSync null return; genNewSequence() removed
v1.2.0 v1.2.1 Low SqsService added (new feature)
v1.2.1 v1.2.2 Low CsvBatchProcessor Poison Pill fix (transparent)
v1.2.x v1.2.4 Medium TaskModule.register() must be in AppModule
v1.2.4 v1.2.5 Low ZIP import refactor (transparent); MCP AP016–AP020 added
v1.2.5 v1.2.6 Low Repository RYW improvements (transparent); getVersion API
v1.2.7 v1.3.0 Low AppSync Events API support (opt-in, no breaking changes)
v1.3.0 v1.3.1 Low Group-based roles (@GroupRoleResolver, custom:groups); UserContext.tenantRoles/tenantGroupIds added (opt-in, no breaking changes)
v1.3.2 v1.3.3 Low ATTRIBUTE_LIMIT_SIZE default lowered 389120 → 102400 for Step Functions payload limit (transparent unless overridden)
v1.3.5 v1.4.0 Low Configurable DynamoDB table names (opt-in) + registerAsync on domain modules; prismaService now always required; MasterModule+SettingModule shared-table alias warns
v1.4.0 v1.5.0 Low Local dev only: LocalStack → Floci as the local S3 emulator; bucket CORS must be applied locally for presigned URLs (no application code changes)

Migration Guides

v1.0.16 → v1.0.17

Breaking Change: MasterDataService.search() settingCode behavior

Before (v1.0.16):

// settingCode was incorrectly using partial match
const results = await masterDataService.search({
  settingCode: 'CONFIG', // Matched 'CONFIG', 'CONFIG_A', 'CONFIG_B'
});

After (v1.0.17):

// settingCode now uses exact match
const results = await masterDataService.search({
  settingCode: 'CONFIG', // Only matches 'CONFIG' exactly
});

Migration Steps:

  1. Review all MasterDataService.search() calls
  2. If you relied on partial matching, update to explicit searches:
    // If you need multiple settings
    const configs = await Promise.all([
      masterDataService.search({ settingCode: 'CONFIG_A' }),
      masterDataService.search({ settingCode: 'CONFIG_B' }),
    ]);
    

v1.0.17 → v1.0.18

New Feature: ImportStatusHandler.sendTaskFailure()

Addition:

// New method available for explicit failure signaling
await importStatusHandler.sendTaskFailure({
  taskToken: event.taskToken,
  error: 'ValidationError',
  cause: 'Invalid CSV format',
});

Migration Steps:

  1. No breaking changes
  2. Optional: Use sendTaskFailure() for better Step Functions integration

v1.0.18 → v1.0.19

Bug Fix: ImportQueueEventHandler error handling

Before (v1.0.18):

  • Parent job status not updated correctly on child failures

After (v1.0.19):

  • Parent job status properly reflects child job failures

Migration Steps:

  1. No code changes required
  2. Verify existing import workflows work correctly

v1.0.19 → v1.0.20

Bug Fix: CsvImportSfnEventHandler status determination

Before (v1.0.19):

  • Incorrect status when all rows failed

After (v1.0.20):

  • Correct FAILURE status when processedRows === 0

Migration Steps:

  1. No code changes required
  2. Review error handling in import workflows

v1.0.20 → v1.0.21

New Feature: ZIP Finalization Hooks

Addition:

// New interface for ZIP import finalization
interface IZipFinalizationHook {
  afterFinalize(context: ZipFinalizationContext): Promise<void>;
}

// Module registration
@Module({
  imports: [
    ImportModule.register({
      zipFinalizationHooks: [MyCustomFinalizationHook],
    }),
  ],
})
export class AppModule {}

Migration Steps:

  1. No breaking changes
  2. Optional: Implement custom finalization hooks for post-import processing

Example Implementation:

@Injectable()
export class NotificationFinalizationHook implements IZipFinalizationHook {
  constructor(private readonly notificationService: NotificationService) {}

  async afterFinalize(context: ZipFinalizationContext): Promise<void> {
    const { results, status } = context;

    if (status === ImportStatusEnum.SUCCESS) {
      await this.notificationService.sendSuccess({
        message: `Import completed: ${results.processedRows} rows processed`,
      });
    } else {
      await this.notificationService.sendFailure({
        message: `Import failed: ${results.failedRows} rows failed`,
      });
    }
  }
}

v1.0.21 → v1.0.22

Bug Fix: DynamoDB export S3 filter

Before (v1.0.21):

  • Export filter not applied correctly

After (v1.0.22):

  • S3 export filter works as expected

Migration Steps:

  1. No code changes required

v1.0.22 → v1.0.23

New Feature: sendInlineTemplateEmail()

Addition:

// New method for inline email templates
await notificationService.sendInlineTemplateEmail({
  to: ['user@example.com'],
  subject: 'Order Confirmation - {{orderId}}',
  htmlBody: '<h1>Thank you for your order!</h1><p>Order ID: {{orderId}}</p>',
  textBody: 'Thank you for your order! Order ID: {{orderId}}',
  templateData: {
    orderId: 'ORD-12345',
  },
});

Migration Steps:

  1. No breaking changes
  2. Optional: Use inline templates instead of SES templates for simple emails


v1.1.0 — Breaking Changes (data migration required)

1. TENANT_COMMON renamed to lowercase

// Before (v1.0.x)
const pk = `MASTER_SETTING#COMMON#${settingCode}`

// After (v1.1.0+)
const pk = `MASTER_SETTING#common#${settingCode}`
  • All DynamoDB keys using #COMMON must be migrated to #common
  • New utilities: normalizeTenantCode(), isCommonTenant()

2. publish() and publishPartialUpdate() removed

// Removed — compilation error in v1.1.0+
this.commandService.publish(...)
this.commandService.publishPartialUpdate(...)

// Use instead
this.commandService.publishAsync(...)
this.commandService.publishPartialUpdateAsync(...)

v1.1.4 — publishSync audit trail (no code changes required)

publishSync now writes a full audit trail to Command and History tables (parity with async pipeline). No migration required; behavior is transparent.


v1.1.5 — CSV Import v2 batch architecture

Step Functions state machine changes required:

  • finalize_parent_job state is now required
  • Row-level progress tracking via import_tmp table is removed
  • Counters (processedRows, succeededRows, failedRows) are aggregated at completion

v1.2.0 — Breaking Changes

1. publishSync / publishPartialUpdateSync return type change

// Before (v1.1.x) — always returned CommandModel
const result = await commandService.publishSync(entity, options)
console.log(result.pk) // safe

// After (v1.2.0+) — returns null when command is not dirty (no-op)
const result = await commandService.publishSync(entity, options)
if (!result) return // no-op
console.log(result.pk) // safe after null check

2. SequenceService.genNewSequence() removed

// Removed — compilation error in v1.2.0+
await sequenceService.genNewSequence(...)

// Use instead
await sequenceService.generateSequenceItem(...)
// or
await sequenceService.generateSequenceItemWithProvideSetting(...)

3. Read-Your-Writes (RYW) consistency (new optional feature)

import { DetailKey, Repository } from '@mbc-cqrs-serverless/core'

// Enable: set RYW_SESSION_TTL_MINUTES env var (e.g. "5")
// Session table {NODE_ENV}-{APP_NAME}-session must be created
// No effect if unset — zero impact on existing projects

v1.2.2 — Bug fixes (no code changes required)

  • CsvBatchProcessor Poison Pill fix: Per-row try/catch now prevents Head-of-Line Blocking in SQS batches
  • ImportQueueEventHandler: singleImportProcessor.process() now receives event.importEvent instead of event.payload

Both fixes are transparent — no migration steps needed.


v1.2.4 — TaskModule global registration

Breaking for apps using @mbc-cqrs-serverless/master

TaskModule.register() is now global (global: true) and must be called exactly once in the host AppModule. MasterModule no longer registers TaskModule internally.

// Before (v1.2.3 and earlier) — worked automatically
@Module({
  imports: [MasterModule.register({ enableController: true, prismaService: PrismaService })],
})
export class AppModule {}

// After (v1.2.4+) — must register explicitly
import { TaskModule, TaskQueueEventFactory } from '@mbc-cqrs-serverless/master'

@Module({
  imports: [
    TaskModule.register({ taskQueueEventFactory: MyTaskQueueEventFactory }),
    MasterModule.register({ enableController: true, prismaService: PrismaService }),
  ],
})
export class AppModule {}

Migration steps:

  1. Create a factory extending TaskQueueEventFactory from @mbc-cqrs-serverless/master
  2. Call TaskModule.register() once in AppModule
  3. Remove TaskModule.register() from all feature modules

Symptom if skipped: App crashes at startup with Nest can't resolve dependencies of MyTaskService (?).


v1.2.5 — ZIP import refactor + MCP anti-pattern expansion (no migration steps required)

Framework changes (transparent):

  • ZipImportQueueEventHandler removed; ZIP import jobs are now processed directly inside ImportService.
  • ImportEventHandler no longer publishes SQS messages for ZIP_MASTER_JOB events.
  • Enhanced ZIP import validation in CreateZipImportDto and improved error handling/logging.

No application code changes are required. If you previously imported ZipImportQueueEventHandler directly (uncommon), remove the import.

MCP server changes:

  • mbc_check_anti_patterns tool expanded from 15 to 20 detectors (AP016–AP020 added):
    • AP016: Missing Error Logging Before Rethrow (High)
    • AP017: Incorrect Attribute Merging on Partial Update (High)
    • AP018: Missing Swagger Documentation / @ApiTags (Low)
    • AP019: Missing Pagination in List Queries (High)
    • AP020: Missing getCommandSource for Tracing (Low)
  • @modelcontextprotocol/sdk updated 1.26.0 → 1.29.0.

v1.2.6 — Repository RYW improvements (no migration steps required)

The Repository class now actively purges stale RYW sessions when the data table catches up to or surpasses the session version. This eliminates the "stale override" issue where a user could see their own older write even after an external update was already absorbed into the data table.

Behavior changes (transparent — no code changes required):

  • getItem: when existing.version >= session.version, the session is purged in the background and the persisted data is returned directly (skipping the unnecessary command-table read).
  • listItemsByPk: synchronized sessions are cleaned up in-place during the merge loop.
  • listItems (RDS path): per-session checks are now parallelized via Promise.all, eliminating sequential N+1 latency.

New optional optimization for listItems:

// If your RDS rows already carry `version`, supply `getVersion` to skip the
// extra DynamoDB GetItem entirely when the existing RDS row proves caught-up.
await repository.listItems(
  () => rdsQuery(),
  {
    latestFlg: true,
    transformCommand: (cmd) => ({
      id: cmd.id,
      version: cmd.version,
      // ...map other CommandModel fields to your RDS row shape
    }),
    getVersion: (item) => item.version,  // ← new in v1.2.6
  },
  options,
)

Note: getVersion only short-circuits the update path (when the session's itemId matches an existing RDS row). Create-new items (session present but not yet reflected in RDS) still fetch the command as before — there is no existing row to derive a version from.

All changes are backward-compatible: getVersion is optional, and the cleanup behavior is automatic when RYW_SESSION_TTL_MINUTES is enabled. Projects with RYW disabled (env var unset) are unaffected.


Deprecated API Migration

publish() → publishAsync()

Deprecated in: v1.0.0 Removed in: TBD

Before:

await this.commandService.publish(command, options);

After:

await this.commandService.publishAsync(command, options);

Migration Script:

# Find all occurrences
grep -r "\.publish(" --include="*.ts" src/

# Replace (use with caution)
find src/ -name "*.ts" -exec sed -i '' 's/\.publish(/\.publishAsync(/g' {} \;

publishPartialUpdate() → publishPartialUpdateAsync()

Deprecated in: v1.0.0 Removed in: TBD

Before:

await this.commandService.publishPartialUpdate(command, options);

After:

await this.commandService.publishPartialUpdateAsync(command, options);

publishSync() Considerations

Status: Not deprecated, but use sparingly

Recommendation:

// Only use publishSync when immediate consistency is required
// Example: User registration where we need the user ID immediately
const result = await this.commandService.publishSync(command, options);

// For most cases, prefer publishAsync
await this.commandService.publishAsync(command, options);

Migration Checklist

Before Migration

  • Backup database
  • Review changelog for target version
  • Check for breaking changes
  • Run test suite on current version
  • Document current API usage patterns

During Migration

  • Update package.json dependencies
  • Run npm install
  • Fix any TypeScript compilation errors
  • Update deprecated API calls
  • Run test suite
  • Test critical workflows manually

After Migration

  • Deploy to staging environment
  • Run E2E tests
  • Monitor error logs
  • Verify data integrity
  • Update documentation
  • Deploy to production

Automated Migration Checks

When using this skill, Claude will:

  1. Analyze Current Version:

    npm list @mbc-cqrs-serverless/core
    
  2. Check for Deprecated Usage:

    # Search for deprecated patterns
    grep -r "\.publish(" --include="*.ts" src/
    grep -r "\.publishPartialUpdate(" --include="*.ts" src/
    grep -r "publishSync" --include="*.ts" src/
    
  3. Identify Breaking Changes:

    • Compare current usage against migration guide
    • Flag any patterns that need updating
  4. Generate Migration Plan:

    • List all files requiring changes
    • Provide specific code updates needed
    • Estimate migration effort

Common Migration Issues

Issue: ConditionalCheckFailedException after update

Cause: Version field handling changed Solution:

// Always fetch current version before update
const existing = await this.dataService.getItem(key);
await this.commandService.publishPartialUpdateAsync({
  ...updateData,
  version: existing.version,
}, options);

Issue: DataSyncHandler not triggering

Cause: Type mismatch in decorator Solution:

// Ensure type matches entity's type field
@DataSyncHandler({ type: 'ORDER' }) // Must match command.type
export class OrderDataSyncRdsHandler implements IDataSyncHandler {}

Issue: Import job stuck in PROCESSING

Cause: Missing error handling in v1.0.18 Solution: Upgrade to v1.0.19+ for proper error propagation



v1.2.7 → v1.3.0

Change: AppSync Events API support added (opt-in, no breaking changes)

No code changes are required. AppSyncEventsService is registered in NotificationModule automatically but does nothing unless NOTIFICATION_TRANSPORTS includes appsync-event.

To adopt the new Events API transport:

Step 1 — Provision infrastructure (CDK)

Add appsyncEvents and notificationTransports to your Config:

// infra/config/config.ts
export const config: Config = {
  appsyncEvents: {
    enabled: true,
    namespace: 'default',       // must match the ChannelNamespace created in AppSync
    apiKeyExpireDays: 365,
  },
  notificationTransports: 'appsync-graphql,appsync-event', // dual-publish during migration
}

Run cdk deploy to create the EventApi and ChannelNamespace. The stack outputs AppSyncEventsHttpEndpoint and AppSyncEventsNamespace.

Step 2 — Enable dual-publish (migration phase)

When appsyncEvents.enabled: true, the CDK stack automatically injects these env vars into Lambda/ECS:

NOTIFICATION_TRANSPORTS=appsync-graphql,appsync-event   # dual-publish
APPSYNC_EVENTS_ENDPOINT=https://xxxx.appsync-api.ap-northeast-1.amazonaws.com/event
APPSYNC_EVENTS_NAMESPACE=default
APPSYNC_ENDPOINT=https://xxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql  # keep existing

Step 3 — Switch to Events API only

Once all clients have migrated to the Events API, update notificationTransports in CDK Config:

notificationTransports: 'appsync-event',  // Events API only

Then redeploy. APPSYNC_ENDPOINT can be removed from the environment.

Channel subscription patterns (for client-side code):

// All events for a tenant
appsyncClient.subscribe(`/${namespace}/${tenantCode}/*`)

// Filtered by action
appsyncClient.subscribe(`/${namespace}/${tenantCode}/${action}/*`)

// Track one specific command result
appsyncClient.subscribe(`/${namespace}/${tenantCode}/${action}/${sanitizedId}`)

Non-alphanumeric characters in id (e.g. #, @ from DynamoDB pk/sk) are sanitized to - automatically on the server side. Client-side code using EventsSubscriptionClientImpl from the example app applies the same sanitization.

Migration Steps:

  1. Add appsyncEvents and notificationTransports to CDK Config and deploy
  2. Verify events arrive via both transports (dual-publish phase)
  3. Migrate frontend clients to subscribe via AppSync Events API channels
  4. Once all clients migrated, set notificationTransports: 'appsync-event' and redeploy

v1.3.0 → v1.3.1

Change: Group-based roles added (opt-in, no breaking changes).

RolesGuard now checks direct roles from custom:roles first, then roles derived from the user's groups in custom:groups. UserContext gains two fields — tenantRoles (direct roles array) and tenantGroupIds (group IDs for the active tenant) — while tenantRole (singular) is kept for backward compatibility. No code changes are required to upgrade; existing role checks keep working.

To adopt group-based roles:

Step 1 — Add custom:groups to the JWT (tenant-scoped; group → role mappings are NOT in the token):

{
  "custom:roles": "[{\"tenant\":\"tenant-a\",\"role\":\"admin\"}]",
  "custom:groups": "[{\"tenant\":\"tenant-a\",\"groups\":[\"sales-team\"]}]"
}

Step 2 — Implement exactly one resolver that maps group IDs to roles (loaded from DynamoDB/RDS/config):

import {
  GroupRoleResolver,
  IGroupRoleResolver,
} from '@mbc-cqrs-serverless/core';

// Do NOT add @Injectable() — @GroupRoleResolver() already registers the provider
// as a singleton. A second @Injectable() can override the scope and break bootstrap.
@GroupRoleResolver()
export class AppGroupRoleResolver implements IGroupRoleResolver {
  async resolveRoles({ tenantCode, groupIds, claims }) {
    // Map the user's group IDs to roles for this tenant
    return ['viewer', 'reporter'];
  }
}

Register the class in your module providers. AuthModule is imported automatically via AppModule.forRoot().

Notes / gotchas:

  • The resolver must be a singleton — it is resolved once at bootstrap. Avoid REQUEST/TRANSIENT scope.
  • A resolver failure (e.g. DB outage) propagates as a 5xx, not a silent 403, so a backend outage is distinguishable from a genuine access denial. Make the resolver resilient (timeouts/retries) if its backing store can be unavailable.
  • Malformed custom:groups is tolerated (fail-closed → no group roles), so a bad token degrades to direct-role-only checks rather than crashing.
  • getUserRole() on RolesGuard is deprecated and no longer called by the default guard. For custom authorization, override verifyRole, resolveGroupRoles, or canOverrideTenant instead.
  • Role-name matching is case-sensitive; keep the casing in custom:roles consistent with your @Roles(...) values.

Migration Steps:

  1. Upgrade to v1.3.1 — no code changes required; existing @Roles() checks keep working.
  2. (Optional) Populate custom:groups in the JWT and implement one @GroupRoleResolver().
  3. Register the resolver in your NestJS module providers.

v1.3.2 → v1.3.3 (no code changes required, unless you hardcoded the old default)

Change: The framework's default ATTRIBUTE_LIMIT_SIZE in generated CDK templates (infra/libs/infra-stack.ts) and env examples (.env.local, .env.example) is lowered from 389120 (380 KB) to 102400 (100 KB).

Why: The old default was sized for DynamoDB's 400 KB item limit, but AWS Step Functions enforces a 256 KB payload limit per state. The command state machine passes the DynamoDB stream event twice per state (input.$ + context.$), so inline attributes above ~110 KB could trigger States.DataLimitExceeded in production. See Debug Guide — States.DataLimitExceeded for the full symptom/cause/fix.

Action required:

  • If you never customized ATTRIBUTE_LIMIT_SIZE and redeploy your CDK stack, the new lower default takes effect automatically — no code changes needed.
  • If you explicitly set ATTRIBUTE_LIMIT_SIZE=389120 (or another value above ~110 KB) in your own .env or CDK stack, lower it to 102400 or less to avoid States.DataLimitExceeded. Attributes above the limit are automatically offloaded to S3, so lowering the value does not lose data.

v1.3.5 → v1.4.0 (opt-in new features; no changes required if you keep defaults)

v1.4.0 makes the DynamoDB table names of the domain modules (master, directory, survey-template, ui-setting) configurable and adds registerAsync support. All new options default to the current names, so existing apps upgrade with no code changes.

New capabilities (opt-in):

  • tableName? on MasterModule / DirectoryStorageModule / SurveyTemplateModule / SettingModule (defaults: master / directory / survey / master).
  • DirectoryStorageModule also accepts pkPrefix? (default DIRECTORY) and prismaModelName? (default directory).
  • registerAsync(...) on all four modules.

Behavior changes to be aware of (not breaking for the common setup):

  • prismaService is now always required when registering MasterModule / DirectoryStorageModule / SurveyTemplateModule (previously it was only validated when enableController: true). The domain services inject PRISMA_SERVICE unconditionally, so a missing prismaService now fails fast at startup with a clear message instead of a cryptic UnknownDependenciesException. Action: if you registered one of these modules with enableController: false and omitted prismaService (relying on a globally-provided token), pass prismaService explicitly.
  • If you use MasterModule + SettingModule on the same (default master) table, the framework now logs a warning at startup when both own the master_CommandEventHandler alias, and which module's data-sync handlers run is import-order dependent. Set registerEventHandlerAlias: false on SettingModule so MasterModule owns the alias (deterministic). This was a silent ambiguity before; nothing breaks, but the warning is new.

How to change a table name

Changing a table name is opt-in and backward compatible. Example: run the directory module as document (this is how the v1.3.x directory → document rename is now expressed — as configuration, not a forced default):

// Synchronous
DirectoryStorageModule.register({
  enableController: true,
  prismaService: PrismaService,
  tableName: 'document',        // physical tables: <env>-<app>-document-command/-data/-history
  pkPrefix: 'DOCUMENT',         // partition key: DOCUMENT#<tenantCode>
  prismaModelName: 'document',  // RDS reads via prismaService.document
})

// Async — the factory must resolve and return the PrismaService INSTANCE
DirectoryStorageModule.registerAsync({
  tableName: 'document',
  pkPrefix: 'DOCUMENT',
  prismaModelName: 'document',
  imports: [PrismaModule],
  inject: [PrismaService],
  useFactory: (prisma) => ({ prismaService: prisma }),
})

For MasterModule / SurveyTemplateModule / SettingModule, only tableName applies:

MasterModule.register({ enableController: true, prismaService: PrismaService, tableName: 'catalog' })

Set all three (tableName + pkPrefix + prismaModelName) together for directory — changing only tableName leaves the PK as DIRECTORY# and reads prismaService.directory, which is inconsistent.

Required provisioning and data migration (application responsibility):

  1. DynamoDB: add the raw base name (e.g. "document") — not the -command/-data/-history variants — to prisma/dynamodbs/cqrs.json, then run npm run migrate:ddb (prisma/ddb.ts). This creates <name>-command/-data/-history. Only @mbc-cqrs-serverless/master's postinstall auto-adds its default master; every other module and every custom name must be added to cqrs.json by hand. Mirror the three physical tables in your IaC.
  2. RDS: add the corresponding Prisma model (e.g. model Document { ... } for prismaModelName: 'document') to schema.prisma, then run npm run migrate:rds (prisma migrate deploy).
  3. Data: migrate only creates the (empty) tables. Copying existing rows from directory-* / DIRECTORY# / the directory model to the new document-* / DOCUMENT# / document targets is your responsibility (DynamoDB scan-and-rewrite + an RDS data migration). See Data Migration Patterns.

Caveat — do NOT rename the master table. The master table is a framework-wide central config store: TtlService (TTL config) and @mbc-cqrs-serverless/sequence (numbering formats) read a fixed master-data name. Overriding MasterModule's tableName makes those readers miss their config (TTLs not applied, sequence formats fall back silently); MasterModule.register/registerAsync logs a warning in that case. Renaming directory / survey-template / ui-setting has no such caveat.

Action required: none if you keep the defaults. To adopt a custom name, follow the steps above.


v1.4.0 → v1.5.0 (local development stack only; no application code changes)

v1.5.0 replaces LocalStack with Floci (floci/floci:1.6.0) as the local S3 emulator. LocalStack Community Edition reached end of life in March 2026 (it now requires an auth token and no longer receives security updates), and the template pinned no image version. Floci serves S3 on the same port (4566) with the same path-style addressing, so application code and .env values (S3_ENDPOINT, LOCAL_S3_PORT, S3_REGION, S3_BUCKET_NAME) do not change. Deployed environments are not affected.

Newly scaffolded projects (mbc new) already use Floci. Existing projects keep working on LocalStack, but that image no longer receives security patches, so migrate when convenient.

Step 1 — Replace the compose service. In infra-local/docker-compose.yml, replace the localstack service with:

  floci:
    image: floci/floci:1.6.0
    ports:
      - '${LOCAL_S3_PORT:-4566}:4566'
    environment:
      - FLOCI_DEFAULT_REGION=ap-northeast-1
      - FLOCI_STORAGE_MODE=persistent
    volumes:
      - floci-data:/app/data

and declare the named volume at the top level of the same file:

volumes:
  floci-data:

FLOCI_STORAGE_MODE=persistent is required: the default is memory, which silently discards every bucket on docker compose down. The volume is project-scoped through COMPOSE_PROJECT_NAME in .env, so projects on the same machine do not share S3 data.

Step 2 — Remove the plugin. Remove serverless-localstack from package.json (and any commented localStackConfig block in infra-local/serverless.yml), then run npm install.

Step 3 — Add the bucket CORS rule to your resource scripts (required for presigned URLs). LocalStack allowed every origin through EXTRA_CORS_ALLOWED_ORIGINS=*. Floci 1.6.0 does not honor that variable (its own global switch is FLOCI_SECURITY_EXTRA_CORS_ALLOWED_ORIGINS), so without a CORS rule it answers the browser preflight with 403 and presigned upload/view URLs from DirectoryFileService fail in the browser. The template applies a bucket CORS rule instead, mirroring how production configures CORS on the bucket in the CDK stack. Copy the configure S3 bucket CORS block from the latest template's infra-local/scripts/resources.sh / resources.ps1 into your own scripts, so the rule is re-applied every time the scripts run (npm run offline:sls runs them for you).

On Windows, the template passes the JSON as file:// and writes the file without a BOM: Windows PowerShell 5.1 adds one for Set-Content -Encoding utf8, and the AWS CLI rejects it with Error parsing parameter '--cors-configuration'. Keep the template's [System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding $false)) and $LASTEXITCODE check when you copy the block.

Step 4 — Start Floci and recreate the bucket. Objects under docker-data/localstack are not migrated. npm run offline:docker runs docker compose up in the foreground and does not return, so use two terminals from the project root:

# Terminal 1 — keeps running
npm run offline:docker
# Terminal 2 — once Floci is up: creates the bucket and applies the CORS rule
bash infra-local/scripts/resources.sh   # Windows: npm run resources:win32

npm run offline:sls also runs these scripts, but on macOS/Linux it starts them in the background alongside Serverless Offline (not before it, and without stopping on failure); only on Windows does it wait for them. Run resources.sh by hand once as above and confirm it succeeded before you test presigned URLs.

If you did not update the scripts in Step 3, apply the rule once by hand after the bucket exists:

set -a; . ./.env; set +a   # load S3_ENDPOINT, S3_BUCKET_NAME and the local credentials
aws --endpoint-url="$S3_ENDPOINT" s3api put-bucket-cors \
  --bucket "$S3_BUCKET_NAME" \
  --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],"AllowedMethods":["GET","PUT","POST","DELETE","HEAD"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"]}]}'

Verify (from the project root, with .env loaded as above):

aws --endpoint-url="$S3_ENDPOINT" s3 ls
aws --endpoint-url="$S3_ENDPOINT" s3api get-bucket-cors --bucket "$S3_BUCKET_NAME"

Action required: Steps 1–4 for existing projects that want to leave LocalStack. None for application code.


Version Compatibility Matrix

Core Version CLI Version NestJS Node.js TypeScript
v1.5.0 v1.5.0 10.x 18+ 5.x
v1.4.0 v1.4.0 10.x 18+ 5.x
v1.3.0 v1.3.0 10.x 18+ 5.x
v1.0.23 v1.0.23 10.x 18+ 5.x
v1.0.22 v1.0.22 10.x 18+ 5.x
v1.0.21 v1.0.21 10.x 18+ 5.x
v1.0.20 v1.0.20 10.x 18+ 5.x

Getting Help

If you encounter migration issues:

  1. Check the changelog for detailed notes
  2. Search GitHub issues
  3. Review error catalog for specific errors
  4. Ask Claude Code for specific migration assistance