Files
calendar/packages/features/flags/DECORATOR_IMPLEMENTATION_PLAN.md
T
Eunjae LeeGitHubunknown <>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Volnei Munhoz
aa8804e498 feat(flags): add @Memoize and @Unmemoize decorators for declarative caching (#27063)
* feat(flags): add @Memoize and @Unmemoize decorators for declarative caching

- Add @Memoize decorator for caching method results with Zod validation
- Add @Unmemoize decorator for cache invalidation on mutations
- Create UserFeatureRepository using decorators as example implementation
- Add comprehensive tests with 80%+ coverage (19 tests)
- Add DI module and tokens for UserFeatureRepository
- Enable experimentalDecorators in packages/features/tsconfig.json

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): use lazy Redis lookup in decorators

- Decorators now access Redis via getRedisService() instead of this.redis
- UserFeatureRepository no longer has redis property or direct redis calls
- findByUserIdAndFeatureIds now uses decorated findByUserIdAndFeatureId
- DI module simplified to only pass prisma dependency
- Tests updated to call setRedisService() in beforeEach

This ensures the repository only knows about Prisma, with all caching
handled transparently by the decorators.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat(flags): add FeatureRepository and TeamFeatureRepository with decorator-based caching

- Add FeatureRepository with @Memoize decorators for findAll, findBySlug, getFeatureFlagMap
- Add TeamFeatureRepository with @Memoize/@Unmemoize decorators for all CRUD operations
- Add comprehensive Zod schemas for Feature, TeamFeatures, and AppFlags validation
- Add DI modules and tokens for both repositories
- Add comprehensive test coverage (33 tests) for both repositories
- Maintain backward compatibility with existing FEATURES_REPOSITORY tokens

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): use DI container pattern for Redis service

- Create packages/features/di/containers/Redis.ts with getRedisService()
- Update Memoize and Unmemoize decorators to import from DI container
- Remove setRedisService() and IRedisService from types.ts exports
- Update all tests to mock the container's getRedisService
- Fix import ordering issues from biome

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): remove barrel import and add root-level cache export

- Remove packages/features/cache/decorators/index.ts barrel file
- Add packages/features/cache/index.ts as root-level export for cache feature
- Update repository imports to use direct imports from source files
- Follows the pattern: avoid barrel imports at nested levels, keep at feature root

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(flags): minimize repository methods and address PR feedback

- Remove unnecessary methods from FeatureRepository, TeamFeatureRepository, and UserFeatureRepository
- Keep only methods needed by FeatureOptInService
- Update imports to use @calcom/features/cache public API instead of relative paths
- Handle redis.del() errors gracefully in Unmemoize decorator
- Update tests to match simplified repositories

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(cache): use Promise.allSettled for cache invalidation

Cleaner approach than Promise.all with individual .catch() handlers

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat(flags): add setAutoOptIn methods and replace featuresRepository usage in _router.ts

- Add setAutoOptIn method to TeamFeatureRepository with @Unmemoize decorator
- Add setAutoOptIn method to UserFeatureRepository with @Unmemoize decorator
- Replace featuresRepository usage in featureOptIn/_router.ts with new repositories
- TeamFeatureRepository.setAutoOptIn unmemoizes KEY.autoOptInByTeamId(teamId)
- UserFeatureRepository.setAutoOptIn unmemoizes KEY.autoOptInByUserId(userId)

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(flags): use DI containers for TeamFeatureRepository and UserFeatureRepository

- Create TeamFeatureRepository.ts container with getTeamFeatureRepository()
- Create UserFeatureRepository.ts container with getUserFeatureRepository()
- Update _router.ts to use DI containers instead of direct instantiation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(feature-opt-in): use DI containers for FeatureOptInService dependencies

- Update FeatureOptInService to use IFeatureOptInServiceDeps with 3 repositories
- Add helper functions teamFeatureToState() and userFeatureToState()
- Update DI module to use depsMap pattern with featureRepo, teamFeatureRepo, userFeatureRepo
- Create FeatureRepository container file
- Replace all FeaturesRepository method calls with new repository methods

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* test(feature-opt-in): update FeatureOptInService tests for new IFeatureOptInServiceDeps interface

- Update mock structure to use featureRepo, teamFeatureRepo, userFeatureRepo
- Add helper functions createMockTeamFeature and createMockUserFeature
- Update all test cases to use new repository method names
- Add explicit types to satisfy biome lint rules

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor(flags): use DTOs at repository boundaries to prevent Prisma type leakage

- Create FeatureDto, TeamFeaturesDto, UserFeaturesDto in packages/lib/dto/
- Update repository interfaces to return DTOs instead of Prisma types
- Add toDto() transformation methods in repository implementations
- Update FeatureOptInService to use DTOs instead of Prisma types
- Follow data-dto-boundaries.md guidelines for architectural boundaries

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix(flags): update TeamFeatureRepository tests to match DTO structure

Remove assignedAt from test mock data since TeamFeaturesDto only includes:
teamId, featureId, enabled, assignedBy, and updatedAt

Co-Authored-By: unknown <>

* fix(cache): make Redis failures non-blocking in @Memoize decorator

Wrap Redis get and set operations in try-catch blocks to ensure
Redis failures don't break the application flow. If cache read fails,
proceed to fetch from source. If cache write fails, silently ignore
and return the result.

Co-Authored-By: unknown <>

* fix(cache): add logging for Redis failures and remove barrel import

- Add warning logs for Redis failures in @Memoize and @Unmemoize decorators
- Remove packages/lib/dto/index.ts barrel import file
- Update all imports to use direct file paths instead of barrel imports

Co-Authored-By: unknown <>

* fix(cache): sanitize log messages to avoid exposing sensitive data

- Remove cacheKey from log messages (may contain PII like user/team IDs)
- Log only error.message instead of raw error objects
- Addresses Cubic AI feedback (confidence 9/10)

Co-Authored-By: unknown <>

* sanitize log

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei@cal.com>
2026-01-22 16:26:27 +01:00

9.5 KiB

Memoize/Unmemoize Decorator Implementation Plan

Overview

This plan outlines the implementation of @Memoize and @Unmemoize decorators to replace the current CachedXXRepository + PrismaXXRepository + RedisXXRepository pattern (9 classes per 3 entities) with a more declarative approach.

Current State

The current implementation uses three layers per entity:

  • PrismaUserFeatureRepository - Database access
  • RedisUserFeatureRepository - Cache operations (get/set/invalidate)
  • CachedUserFeatureRepository - Orchestrates cache-aside pattern

This results in 9 repository classes for 3 entities (Feature, TeamFeature, UserFeature), with repetitive cache-aside logic in each CachedXXRepository.

Target State

A single repository class per entity with decorators handling caching:

class UserFeatureRepository {
  @Memoize({ key: (userId, featureId) => `features:user:${userId}:${featureId}`, schema: userFeaturesSchema })
  async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise<UserFeatures | null> {
    return this.prisma.userFeatures.findUnique({ where: { userId_featureId: { userId, featureId } } });
  }

  @Unmemoize({ keys: (userId, featureId) => [`features:user:${userId}:${featureId}`] })
  async upsert(userId: number, featureId: FeatureId, enabled: boolean, assignedBy: string): Promise<UserFeatures> {
    return this.prisma.userFeatures.upsert({ ... });
  }
}

Implementation Steps

Step 1: Enable Decorators in tsconfig (if needed)

The packages/features/tsconfig.json extends @calcom/tsconfig/react-library.json. We need to add experimentalDecorators: true to enable legacy decorators.

Note: Many packages already have experimentalDecorators: true (apps/web, apps/api/v2, packages/trpc, etc.), so this is consistent with the codebase.

Step 2: Create Decorator Infrastructure

Create new files in packages/features/cache/:

  1. packages/features/cache/decorators/Memoize.ts

    • Decorator that wraps methods with cache-aside logic
    • Accepts configuration: key, ttl, schema (Zod)
    • Uses IRedisService for cache operations
    • Validates cached data with Zod schema before returning
  2. packages/features/cache/decorators/Unmemoize.ts

    • Decorator that invalidates cache keys after method execution
    • Accepts configuration: keys (function returning array of cache keys)
    • Invalidates specified keys after the wrapped method completes
  3. packages/features/cache/decorators/index.ts

    • Re-exports both decorators
  4. packages/features/cache/decorators/types.ts

    • Shared types for decorator configuration

Step 3: Implement @Memoize Decorator

interface MemoizeConfig<TArgs extends unknown[]> {
  key: (...args: TArgs) => string;
  ttl?: number;
  schema?: ZodSchema;
}

function Memoize<TArgs extends unknown[]>(config: MemoizeConfig<TArgs>) {
  return function <T extends { redis: IRedisService }>(
    target: (this: T, ...args: TArgs) => Promise<unknown>,
    context: ClassMethodDecoratorContext
  ) {
    return async function (this: T, ...args: TArgs) {
      const cacheKey = config.key(...args);
      const cached = await this.redis.get<unknown>(cacheKey);
      
      if (cached !== null) {
        if (config.schema) {
          const parsed = config.schema.safeParse(cached);
          if (parsed.success) return parsed.data;
        } else {
          return cached;
        }
      }
      
      const result = await target.call(this, ...args);
      if (result !== null) {
        await this.redis.set(cacheKey, result, { ttl: config.ttl ?? DEFAULT_TTL });
      }
      return result;
    };
  };
}

Step 4: Implement @Unmemoize Decorator

interface UnmemoizeConfig<TArgs extends unknown[]> {
  keys: (...args: TArgs) => string[];
}

function Unmemoize<TArgs extends unknown[]>(config: UnmemoizeConfig<TArgs>) {
  return function <T extends { redis: IRedisService }>(
    target: (this: T, ...args: TArgs) => Promise<unknown>,
    context: ClassMethodDecoratorContext
  ) {
    return async function (this: T, ...args: TArgs) {
      const result = await target.call(this, ...args);
      const keysToInvalidate = config.keys(...args);
      await Promise.all(keysToInvalidate.map((key) => this.redis.del(key)));
      return result;
    };
  };
}

Step 5: Refactor UserFeatureRepository

Create a new unified UserFeatureRepository that uses decorators:

export class UserFeatureRepository implements IUserFeatureRepository {
  constructor(
    private prisma: PrismaClient,
    private redis: IRedisService
  ) {}

  async findByUserId(userId: number): Promise<UserFeatures[]> {
    return this.prisma.userFeatures.findMany({ where: { userId } });
  }

  @Memoize({
    key: (userId: number, featureId: FeatureId) => `features:user:${userId}:${featureId}`,
    schema: userFeaturesSchema,
  })
  async findByUserIdAndFeatureId(userId: number, featureId: FeatureId): Promise<UserFeatures | null> {
    return this.prisma.userFeatures.findUnique({
      where: { userId_featureId: { userId, featureId } },
    });
  }

  @Memoize({
    key: (userId: number) => `features:user:autoOptIn:${userId}`,
    schema: booleanSchema,
  })
  async findAutoOptInByUserId(userId: number): Promise<boolean> {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { autoOptInFeatures: true },
    });
    return user?.autoOptInFeatures ?? false;
  }

  @Unmemoize({
    keys: (userId: number, featureId: FeatureId) => [`features:user:${userId}:${featureId}`],
  })
  async upsert(userId: number, featureId: FeatureId, enabled: boolean, assignedBy: string): Promise<UserFeatures> {
    return this.prisma.userFeatures.upsert({
      where: { userId_featureId: { userId, featureId } },
      create: { userId, featureId, enabled, assignedBy },
      update: { enabled, assignedBy },
    });
  }

  @Unmemoize({
    keys: (userId: number, featureId: FeatureId) => [`features:user:${userId}:${featureId}`],
  })
  async delete(userId: number, featureId: FeatureId): Promise<void> {
    await this.prisma.userFeatures.delete({
      where: { userId_featureId: { userId, featureId } },
    });
  }

  @Unmemoize({
    keys: (userId: number) => [`features:user:autoOptIn:${userId}`],
  })
  async updateAutoOptIn(userId: number, enabled: boolean): Promise<void> {
    await this.prisma.user.update({
      where: { id: userId },
      data: { autoOptInFeatures: enabled },
    });
  }

  // Methods without caching (complex queries)
  async checkIfUserBelongsToTeamWithFeature(userId: number, slug: string): Promise<boolean> {
    // ... existing implementation
  }

  async checkIfUserBelongsToTeamWithFeatureNonHierarchical(userId: number, slug: string): Promise<boolean> {
    // ... existing implementation
  }
}

Step 6: Update DI Configuration

Update the DI module to use the new unified repository:

// packages/features/flags/di/UserFeatureRepository.module.ts
const loadModule = bindModuleToClassOnToken({
  module: thisModule,
  moduleToken,
  token,
  classs: UserFeatureRepository,
  depsMap: {
    prisma: prismaModuleLoader,
    redis: redisModuleLoader,
  },
});

Step 7: Write Tests

Create comprehensive tests for the decorators:

  1. packages/features/cache/decorators/__tests__/Memoize.test.ts

    • Test cache hit returns cached value
    • Test cache miss fetches from source and caches
    • Test Zod validation on cached data
    • Test null results are not cached
    • Test TTL is respected
  2. packages/features/cache/decorators/__tests__/Unmemoize.test.ts

    • Test cache keys are invalidated after method execution
    • Test multiple keys can be invalidated
    • Test method result is returned correctly

File Structure

packages/features/
  cache/
    decorators/
      __tests__/
        Memoize.test.ts
        Unmemoize.test.ts
      Memoize.ts
      Unmemoize.ts
      types.ts
      index.ts
  flags/
    repositories/
      UserFeatureRepository.ts  (new unified repository)
      # Keep existing files for backward compatibility during migration
    di/
      UserFeatureRepository.module.ts  (new module)
      tokens.ts  (add new token)

Migration Strategy

  1. Create the new decorator infrastructure alongside existing code
  2. Create the new unified UserFeatureRepository with decorators
  3. Add new DI module for the unified repository
  4. Update consumers to use the new repository
  5. Remove old CachedXXRepository, RedisXXRepository files after migration is complete

Benefits

  1. Reduced boilerplate: 1 class instead of 3 per entity
  2. Declarative caching: Cache behavior is visible at method level
  3. Co-located logic: Caching and invalidation rules are next to the methods they affect
  4. Type-safe: Full TypeScript support with proper typing
  5. Testable: Decorators can be tested independently
  6. Consistent: Same pattern for all repositories

Considerations

  1. Decorator limitations: Legacy decorators (experimentalDecorators) are used since TC39 decorators have different semantics
  2. Redis dependency: Repository classes must have redis: IRedisService property for decorators to work
  3. Zod validation: Optional but recommended for cached data integrity
  4. TTL: Default 5 minutes, configurable per method

Timeline

  1. Step 1-2: Create decorator infrastructure (~30 min)
  2. Step 3-4: Implement decorators (~30 min)
  3. Step 5: Refactor UserFeatureRepository (~30 min)
  4. Step 6: Update DI configuration (~15 min)
  5. Step 7: Write tests (~45 min)
  6. Type checks and lint (~15 min)
  7. Create PR (~10 min)

Total estimated time: ~3 hours