Skip to content

feat(common,core): Add ReducerAction.Skip and rename ReadModelAction to ProjectionAction#685

Merged
javiertoledo merged 11 commits intomainfrom
copilot/add-reduceraction-skip
Jan 29, 2026
Merged

feat(common,core): Add ReducerAction.Skip and rename ReadModelAction to ProjectionAction#685
javiertoledo merged 11 commits intomainfrom
copilot/add-reduceraction-skip

Conversation

Copy link
Contributor

Copilot AI commented Jan 28, 2026

Implementation Plan

  • 1. Add ReducerAction enum and ReducerResult type to packages/common/src/concepts/reducer-metadata.ts
  • 2. Rename ReadModelAction to ProjectionAction and ReadModelAction.Nothing to ProjectionAction.Skip in packages/common/src/concepts/projection-metadata.ts
  • 3. Update exports in packages/common/src/concepts/index.ts and verify packages/common/src/index.ts
  • 4. Update packages/core/src/services/event-store.ts to handle ReducerAction.Skip in reducers
  • 5. Update packages/core/src/services/read-model-store.ts to use ProjectionAction instead of ReadModelAction
  • 6. Add tests for ReducerAction.Skip in packages/core/test/services/event-store.test.ts
  • 7. Update tests to use ProjectionAction in packages/core/test/services/read-model-store.test.ts
  • 8. Update documentation in docs/content/architecture/entity.md
  • 9. Update documentation in docs/content/architecture/read-model.md
  • 10. Build and test to ensure all changes work correctly
  • 11. Remove backward compatibility and move tests to correct location per review feedback
  • 12. Fix return types to use ReducerAction.Skip instead of undefined
Original prompt

This section details on the original issue you should resolve

<issue_title>feat(common,core): Add ReducerAction.Skip and rename ReadModelAction to ProjectionAction</issue_title>
<issue_description>## Problem

  1. Entity reducers have no way to signal that an event should be ignored/skipped. When receiving an unexpected event (e.g., an update event for an entity that doesn't exist), reducers must either return an invalid/placeholder entity or throw an exception (which goes against the principle that reducers should be pure).

  2. The existing ReadModelAction enum uses non-semantic names (Nothing instead of Skip).

  3. Naming is inconsistent between concepts - projections have ReadModelAction but reducers have no equivalent.

Proposed Solution

Create a consistent, semantic naming pattern across both reducers and projections:

// For Projections (replace ReadModelAction)
export enum ProjectionAction {
  Skip,    // was ReadModelAction.Nothing
  Delete,  // was ReadModelAction.Delete
}
export type ProjectionResult<TReadModel> = TReadModel | ProjectionAction

// For Reducers (new)
export enum ReducerAction {
  Skip,
}
export type ReducerResult<TEntity> = TEntity | ReducerAction

Example Usage

Entity Reducer

import { ReducerAction, ReducerResult } from '@magek/common'

@Entity
export class Product {
  constructor(readonly id: UUID, readonly name: string) {}

  @Reduces(ProductUpdated)
  public static reduceProductUpdated(
    event: ProductUpdated,
    current?: Product
  ): ReducerResult<Product> {
    if (!current) {
      // Can't update a non-existent product - skip this event
      return ReducerAction.Skip
    }
    return new Product(current.id, event.newName)
  }
}

Read Model Projection

import { ProjectionAction, ProjectionResult } from '@magek/common'

@ReadModel
export class UserReadModel {
  @Projects(User, 'id')
  public static projectUser(
    entity: User,
    current?: UserReadModel
  ): ProjectionResult<UserReadModel> {
    if (entity.deleted) {
      return ProjectionAction.Delete
    }
    if (!hasRelevantChanges(entity, current)) {
      return ProjectionAction.Skip
    }
    return new UserReadModel(entity.id, entity.name)
  }
}

Files to Modify

File Changes
packages/common/src/concepts/reducer-metadata.ts Add ReducerAction, ReducerResult
packages/common/src/concepts/projection-metadata.ts Replace ReadModelAction with ProjectionAction
packages/common/src/index.ts Update exports
packages/core/src/services/event-store.ts Handle ReducerAction.Skip
packages/core/src/services/read-model-store.ts Use ProjectionAction
docs/content/architecture/entity.md Document ReducerAction.Skip
docs/content/architecture/read-model.md Use ProjectionAction
packages/core/test/services/event-store.test.ts Add reducer skip tests
packages/core/test/services/read-model-store.test.ts Use ProjectionAction

Summary

Concept Skip Delete
Entity Reducer ReducerAction.Skip N/A (events are immutable)
Read Model Projection ProjectionAction.Skip ProjectionAction.Delete

Comments on the Issue (you are @copilot in this section)


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI changed the title [WIP] Add ReducerAction.Skip and rename ReadModelAction to ProjectionAction feat(common,core): Add ReducerAction.Skip and rename ReadModelAction to ProjectionAction Jan 28, 2026
Copilot AI requested a review from javiertoledo January 28, 2026 21:46
@javiertoledo javiertoledo requested a review from Copilot January 29, 2026 00:41
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a consistent API for signaling skip behavior across entity reducers and read model projections in the Magek Framework. It adds ReducerAction.Skip for entity reducers and renames ReadModelAction to ProjectionAction with more semantic naming (Skip instead of Nothing), while maintaining backward compatibility.

Changes:

  • Adds ReducerAction enum with Skip value to allow entity reducers to ignore events without throwing errors
  • Renames ReadModelAction to ProjectionAction with semantic naming improvements, maintaining backward compatibility through deprecation
  • Updates core implementation to handle skip behavior correctly in both event-store and read-model-store services

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/common/src/concepts/reducer-metadata.ts Introduces ReducerAction enum and ReducerResult type for entity reducers
packages/common/src/concepts/projection-metadata.ts Renames ReadModelAction to ProjectionAction with backward-compatible deprecation
packages/core/src/services/event-store.ts Implements ReducerAction.Skip handling in snapshot creation logic
packages/core/src/services/read-model-store.ts Updates projection handling to use ProjectionAction enum
packages/core/test/services/event-store.test.ts Adds comprehensive tests for ReducerAction.Skip behavior
packages/core/test/services/read-model-store.test.ts Updates tests to use ProjectionAction instead of ReadModelAction
docs/content/architecture/entity.md Documents ReducerAction.Skip with usage examples and best practices
docs/content/architecture/read-model.md Updates documentation to use ProjectionAction with clearer semantic naming
common/changes/@magek/common/copilot-add-reduceraction-skip_2026-01-28-21-40.json Rush change file for common package version bump
common/changes/@magek/core/copilot-add-reduceraction-skip_2026-01-28-21-40.json Rush change file for core package version bump
common/config/rush/pnpm-lock.yaml Updates workspace dependencies to reflect version bumps
Files not reviewed (1)
  • common/config/rush/pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (2)

docs/content/architecture/read-model.md:200

  • The documentation example uses ProjectionAction.Skip and ProjectionResult without showing the necessary import statement. Add an import statement at the beginning of the code example to help developers understand where these values come from.

The import should be: import { ProjectionAction, ProjectionResult, FilterFor } from '@magek/common'

```typescript
@ReadModel({
  authorize: 'all',
})
export class CarPurchasesReadModel {
  @Field(type => UUID)
  public id!: UUID

  @Field()
  readonly carModel?: CarModel

  @Field()
  readonly carOwner?: CarOwner

  @Field()
  readonly offers?: Array<CarOffers>

  @Projects(CarModel, (carModel: CarModel): FilterFor<CarPurchasesReadModel> => {
    return {
      carModel: {
        id: {
          eq: carModel.id,
        },
      },
    }
  })
  public static projectWithModel(
    model: CarModel,
    readModelId: UUID | undefined,
    oldCarPurchaseReadModel?: CarPurchasesReadModel
  ): ProjectionResult<CarPurchasesReadModel> {
    if (!readModelId) {
      return ProjectionAction.Skip
    }
    return new CarPurchasesReadModel(
      readModelId,
      model,
      oldCarPurchaseReadModel?.carOwner,
      oldCarPurchaseReadModel?.offers
    )
  }
}
**docs/content/architecture/read-model.md:238**
* The documentation examples use `ProjectionAction.Skip` and `ProjectionAction.Delete` without showing the necessary import statement. Add an import statement at the beginning of the code example to help developers understand where these values come from.

The import should be: `import { ProjectionAction, ProjectionResult } from '@magek/common'`
@ReadModel({
  authorize: 'all',
})
export class UserReadModel {
  @Field(type => UUID)
  public id!: UUID

  @Field()
  readonly username!: string

  @Projects(User, 'id')
  public static projectUser(entity: User, current?: UserReadModel): ProjectionResult<UserReadModel>  {
    if (current?.deleted) {
      return ProjectionAction.Delete
    }
    return new UserReadModel(...)
  }
</details>

Comment on lines 64 to 67
// If reducer returns undefined (Skip action), keep the current snapshot unchanged
if (reducerResult !== undefined) {
newEntitySnapshot = reducerResult
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Returning undefined is not allowed. We should either return an entity or the new ReducerAction.Skip value.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 45a6975. The createNewSnapshot and entityReducer methods now return ReducerAction.Skip directly instead of undefined. The loop in fetchEntitySnapshot checks for ReducerAction.Skip explicitly (line 65). Tests also updated to return ReducerAction.Skip instead of undefined.

…ip tests to correct location

Co-authored-by: javiertoledo <175096+javiertoledo@users.noreply.github.com>
Co-authored-by: javiertoledo <175096+javiertoledo@users.noreply.github.com>
Replace undefined returns with ReducerAction.Skip in internal reducer
methods to align with the public ReducerResult<TEntity> type signature.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@javiertoledo javiertoledo marked this pull request as ready for review January 29, 2026 20:19
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

} else {
logger.debug(`Reduced new snapshot for entity ${entityName} with ID ${entityID}: `, newEntitySnapshot)
}
return await this.storeSnapshot(newEntitySnapshot)

P2 Badge Avoid storing unchanged snapshot when reducers skip all events

When all pending events return ReducerAction.Skip, newEntitySnapshot stays as the previously loaded snapshot (initialized earlier in this method) and this block still calls storeSnapshot, which writes a duplicate snapshot with the old snapshottedEventCreatedAt. That contradicts the new Skip semantics (“don’t store a new snapshot”) and can cause unbounded snapshot growth plus repeated writes on every read for entities that keep skipping events (e.g., updates for missing entities). Consider tracking whether any reducer actually produced a new snapshot and only calling storeSnapshot when the snapshot was updated.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Add tests verifying that reduceEntityTouched:
- Returns updated snapshot when touching an existing entity
- Returns ReducerAction.Skip when touching a non-existent entity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@javiertoledo javiertoledo merged commit e02605e into main Jan 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(common,core): Add ReducerAction.Skip and rename ReadModelAction to ProjectionAction

2 participants