Skip to content

Conversation

@kerry-linn
Copy link
Contributor

@kerry-linn kerry-linn commented Jan 7, 2026

Summary

Implements automatic deletion of expired listings after 6 months archival period, following Azure Functions timer trigger pattern.

Closes #200

Changes

Timer Handler

  • Added expired-listing-deletion-handler.ts - Azure Functions timer that runs daily at 2 AM (NCRONTAB: 0 0 2 * * *)
  • Integrated OpenTelemetry tracing for observability and monitoring

Application Services

  • Added processExpiredDeletions service to batch delete expired/cancelled listings older than 6 months
  • Added deleteByListing service to clean up related conversations when a listing is deleted
  • Added forSystemTask() factory method for system-level operations without user context (e.g., timer-triggered tasks)

Domain Layer

  • Added requestDelete() method to Conversation aggregate (following existing ItemListing pattern)
  • Added deleteBlob() method to BlobStorage service interface for image cleanup

Persistence Layer

  • Added getExpiredForDeletion(archivalMonths, limit) to ItemListingReadRepository
  • Added getByListingId(listingId) to ConversationReadRepository

Infrastructure

  • Added @azure/storage-blob to service-blob-storage for blob cleanup operations
  • Added @sthrift/domain dependency to context-spec package

Testing

  • Comprehensive unit tests for all new services (process-expired-deletions, delete-by-listing)
  • BDD feature files following project conventions
  • Timer handler tests with OpenTelemetry mocks
  • Tests for new read repository methods (getExpiredForDeletion, getByListingId)

How to Test

  1. Create a listing in the dev environment
  2. Manually update the listing's state to 'Expired' and updatedAt to more than 6 months ago
  3. Run the timer manually or wait for the 2 AM trigger
  4. Verify the listing, associated conversations, and images are deleted

Architecture

Timer Trigger (2 AM daily)
    └── processExpiredDeletions()
        ├── getExpiredForDeletion() - Find listings in Expired/Cancelled state older than 6 months
        └── For each listing:
            ├── deleteBlob() - Delete associated images from blob storage
            ├── deleteByListing() - Delete all related conversations
            └── requestDelete() + save() - Mark listing as deleted (hard delete in persistence)

Summary by Sourcery

Add scheduled processing and permanent cleanup of expired listings, including conversations and images, wired into the Azure Functions host.

New Features:

  • Introduce an Azure Functions timer infrastructure hook and a concrete timer that runs nightly to process expired listing deletions.
  • Add application services to batch delete expired or cancelled listings after a configurable archival period, including associated conversations and stored images.
  • Expose system-task application services (without user context) for use by background jobs like the expired listing deletion timer.

Enhancements:

  • Extend listing and conversation application and domain layers with deletion orchestration helpers and configuration for archival/deletion behaviour.
  • Expand read repositories with queries for expired listings and conversations by listing ID, with improved ObjectId validation and error logging.
  • Augment blob storage service with blob deletion support using Azure Blob Storage SDK.
  • Refine bootstrap and REST/GraphQL handler wiring to use a generic application services host and support timer handlers.

Build:

  • Add Azure Blob Storage client dependency to the blob storage service package and domain context-spec dependency wiring for new configuration and services.

Tests:

  • Add BDD-style tests and feature files for processing expired listing deletions and deleting conversations by listing ID.
  • Extend repository tests and features for new read methods (expired listings for deletion, conversations by listing ID) and improved Mongoose query mocking.

## Summary
Implements automatic deletion of expired listings after 6 months archival period, following Azure Functions timer trigger pattern.

## Changes
### Timer Handler
- Added `expired-listing-deletion-handler.ts` - Azure Functions timer that runs daily at 2 AM
- Integrated OpenTelemetry tracing for observability

### Application Services
- Added `processExpiredDeletions` service to batch delete expired/cancelled listings older than 6 months
- Added `deleteByListing` service to clean up related conversations
- Added `forSystemTask()` factory method for system-level operations without user context

### Domain Layer
- Added `requestDelete()` method to Conversation aggregate (following ItemListing pattern)
- Added `deleteBlob()` method to BlobStorage service interface

### Persistence Layer
- Added `getExpiredForDeletion()` to ItemListingReadRepository
- Added `getByListingId()` to ConversationReadRepository

### Infrastructure
- Added `@azure/storage-blob` to service-blob-storage for blob cleanup
- Added `@sthrift/domain` dependency to context-spec

## Testing
- Comprehensive unit tests for all new services
- Feature files for BDD testing
- Timer handler tests with OpenTelemetry mocks

Closes #200
@kerry-linn kerry-linn requested a review from a team January 7, 2026 22:09
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Jan 7, 2026

Reviewer's Guide

Implements a scheduled Azure Functions timer that runs daily to hard-delete expired or cancelled listings older than a configurable archival period, including associated conversations and listing images, by extending Cellix bootstrap, application services, domain, persistence, blob storage infrastructure, and tests.

Sequence diagram for expired listing deletion timer workflow

sequenceDiagram
    participant Timer as AzureTimer_processExpiredListingDeletions
    participant Cellix as CellixApp
    participant AppHost as ApplicationServicesFactory
    participant ListingSvc as ListingContextApplicationService
    participant ItemListingSvc as ItemListingApplicationService
    participant ReadListingRepo as ItemListingReadRepository
    participant BlobStorage as BlobStorage
    participant ConvSvc as ConversationApplicationService
    participant ReadConvRepo as ConversationReadRepository
    participant ConvUoW as ConversationUnitOfWork
    participant ListingUoW as ItemListingUnitOfWork

    Timer->>Cellix: trigger timer processExpiredListingDeletions
    Cellix->>AppHost: get applicationServicesHost
    AppHost->>AppHost: forSystemTask()
    AppHost-->>ListingSvc: Listing
    AppHost-->>ConvSvc: Conversation

    Timer->>ItemListingSvc: processExpiredDeletions()
    ItemListingSvc->>ReadListingRepo: getExpiredForDeletion(archivalMonths,batchSize)
    ReadListingRepo-->>ItemListingSvc: expiredListings[]

    loop for each listing in expiredListings
        alt blobStorage is configured and listing has images
            ItemListingSvc->>BlobStorage: deleteBlob(containerName,imagePath) * N
            BlobStorage-->>ItemListingSvc: void
        else no blobStorage or no images
            ItemListingSvc-->>ItemListingSvc: skip image deletion
        end

        ItemListingSvc->>ConvSvc: deleteByListing(listingId)
        ConvSvc->>ReadConvRepo: getByListingId(listingId)
        ReadConvRepo-->>ConvSvc: conversations[]

        loop for each conversation
            ConvSvc->>ConvUoW: withScopedTransaction(callback)
            ConvUoW->>ConvUoW: get(conversationId)
            ConvUoW->>ConvUoW: domainConversation.requestDelete()
            ConvUoW->>ConvUoW: save(domainConversation)
            ConvUoW-->>ConvSvc: void
        end

        ItemListingSvc->>ListingUoW: withScopedTransaction(callback)
        ListingUoW->>ListingUoW: get(listingId)
        ListingUoW->>ListingUoW: domainListing.requestDelete()
        ListingUoW->>ListingUoW: save(domainListing)
        ListingUoW-->>ItemListingSvc: void
    end

    ItemListingSvc-->>Timer: ProcessExpiredDeletionsResult
Loading

Class diagram for application services and cleanup workflow

classDiagram
    class ListingDeletionConfig {
        +number archivalMonths
        +number batchSize
        +string blobContainerName
    }

    class ProcessExpiredDeletionsResult {
        +number deletedCount
        +string[] deletedListingIds
        +number deletedConversationsCount
        +number deletedImagesCount
        +ErrorEntry[] errors
    }

    class ErrorEntry {
        +string listingId
        +string error
    }

    class DeleteByListingResult {
        +number deletedCount
        +string[] deletedConversationIds
        +ConvErrorEntry[] errors
    }

    class ConvErrorEntry {
        +string conversationId
        +string error
    }

    class ItemListingApplicationService {
        +create(command)
        +queryById(id)
        +queryByUser(userId)
        +queryAll()
        +cancel(command)
        +update(command)
        +deleteListings(command)
        +unblock(command)
        +queryPaged(criteria,page,pageSize)
        +processExpiredDeletions() ProcessExpiredDeletionsResult
    }

    class ConversationApplicationService {
        +create(command)
        +queryById(id)
        +queryByUser(userId)
        +sendMessage(command)
        +deleteByListing(listingId) DeleteByListingResult
    }

    class ListingContextApplicationService {
        +ItemListing ItemListingApplicationService
    }

    class ConversationContextApplicationService {
        +Conversation ConversationApplicationService
    }

    class SystemTaskServices {
        +Listing ListingContextApplicationService
        +Conversation ConversationContextApplicationService
    }

    class ApplicationServicesFactory {
        +forRequest(rawAuthHeader,hints) ApplicationServices
        +forSystemTask() SystemTaskServices
    }

    class ApplicationServices {
        +Listing ListingContextApplicationService
        +Conversation ConversationContextApplicationService
        +ReservationRequest any
        +User any
    }

    class DataSources {
        +readonlyDataSource ReadonlyDataSource
        +domainDataSource DomainDataSource
    }

    class ReadonlyDataSource {
        +Listing ListingReadonly
        +Conversation ConversationReadonly
    }

    class ListingReadonly {
        +ItemListing ItemListingReadonly
    }

    class ItemListingReadonly {
        +ItemListingReadRepo ItemListingReadRepository
    }

    class ConversationReadonly {
        +Conversation ConversationReadOnlyInner
    }

    class ConversationReadOnlyInner {
        +ConversationReadRepo ConversationReadRepository
    }

    class DomainDataSource {
        +Listing ListingDomain
        +Conversation ConversationDomain
    }

    class ListingDomain {
        +ItemListing ItemListingDomain
    }

    class ItemListingDomain {
        +ItemListingUnitOfWork ItemListingUnitOfWork
    }

    class ConversationDomain {
        +Conversation ConversationDomainInner
    }

    class ConversationDomainInner {
        +ConversationUnitOfWork ConversationUnitOfWork
    }

    class ItemListingReadRepository {
        +getById(id)
        +getByUser(userId)
        +getPaged(criteria,page,pageSize)
        +getExpiredForDeletion(archivalMonths,limit) ItemListingEntityReference[]
    }

    class ConversationReadRepository {
        +getAll()
        +getById(id)
        +getByUser(userId)
        +getBySharerReserverListing(sharerId,reserverId,listingId,options) ConversationEntityReference
        +getByListingId(listingId,options) ConversationEntityReference[]
    }

    class Conversation {
        +requestDelete() void
    }

    class BlobStorage {
        +createValetKey(storageAccount,path,expiration) string
        +deleteBlob(containerName,blobPath) void
    }

    class ServiceBlobStorage {
        -BlobServiceClient blobServiceClient
        +startUp() BlobStorage
        +createValetKey(storageAccount,path,expiration) string
        +deleteBlob(containerName,blobPath) void
        +shutDown() void
    }

    ItemListingApplicationService --> ProcessExpiredDeletionsResult
    ConversationApplicationService --> DeleteByListingResult

    ListingContextApplicationService --> ItemListingApplicationService
    ConversationContextApplicationService --> ConversationApplicationService

    ApplicationServicesFactory --> ApplicationServices
    ApplicationServicesFactory --> SystemTaskServices

    ApplicationServices --> ListingContextApplicationService
    ApplicationServices --> ConversationContextApplicationService

    SystemTaskServices --> ListingContextApplicationService
    SystemTaskServices --> ConversationContextApplicationService

    ItemListingApplicationService --> ItemListingReadRepository
    ItemListingApplicationService --> ItemListingUnitOfWork
    ItemListingApplicationService --> BlobStorage

    ConversationApplicationService --> ConversationReadRepository
    ConversationApplicationService --> ConversationUnitOfWork

    ServiceBlobStorage ..|> BlobStorage

    Conversation ..> ConversationApplicationService : uses
    Conversation ..> ConversationUnitOfWork : persisted via

    DataSources --> ReadonlyDataSource
    DataSources --> DomainDataSource
    ReadonlyDataSource --> ListingReadonly
    ReadonlyDataSource --> ConversationReadonly
    ListingReadonly --> ItemListingReadRepository
    ConversationReadonly --> ConversationReadRepository
    DomainDataSource --> ListingDomain
    DomainDataSource --> ConversationDomain
    ListingDomain --> ItemListingUnitOfWork
    ConversationDomain --> ConversationUnitOfWork
Loading

Class diagram for Cellix bootstrap and Azure Functions timer integration

classDiagram
    class RequestScopedHost~S,H~ {
        +forRequest(rawAuthHeader,hints) S
    }

    class AppHost~AppServices~ {
        +forRequest(rawAuthHeader,hints) AppServices
    }

    class PendingHandler~AppServices~ {
        +string name
        +HttpFunctionOptions options
        +createHandler(applicationServicesHost) HttpHandler
    }

    class PendingTimerHandler~AppServices~ {
        +string name
        +string schedule
        +createHandler(applicationServicesHost) TimerHandler
    }

    class AzureFunctionHandlerRegistry~ContextType,AppServices~ {
        +registerAzureFunctionHttpHandler(name,options,handlerCreator) AzureFunctionHandlerRegistry
        +registerAzureFunctionTimerHandler(name,schedule,handlerCreator) AzureFunctionHandlerRegistry
        +startUp() StartedApplication
    }

    class Cellix~ContextType,AppServices~ {
        -ContextType contextInternal
        -RequestScopedHost~AppServices,unknown~ appServicesHostInternal
        -contextCreatorInternal(serviceRegistry) ContextType
        -appServicesHostBuilder(infrastructureContext) RequestScopedHost~AppServices,unknown~
        -servicesInternal Map
        -pendingHandlers PendingHandler~AppServices~[]
        -pendingTimerHandlers PendingTimerHandler~AppServices~[]
        -Phase phase
        +static initializeInfrastructureServices(registerServices) ContextBuilder
        +registerInfrastructureService(service) InfrastructureServiceRegistry
        +setContext(contextCreator) ApplicationServicesInitializer
        +initializeApplicationServices(factory) AzureFunctionHandlerRegistry
        +registerAzureFunctionHttpHandler(name,options,handlerCreator) AzureFunctionHandlerRegistry
        +registerAzureFunctionTimerHandler(name,schedule,handlerCreator) AzureFunctionHandlerRegistry
        +startUp() StartedApplication
    }

    class InfrastructureServiceRegistry~ContextType,AppServices~ {
        +registerInfrastructureService(service) InfrastructureServiceRegistry
    }

    class ApplicationServicesInitializer~ContextType,AppServices~ {
        +initializeApplicationServices(factory) AzureFunctionHandlerRegistry
    }

    class ContextBuilder~ContextType,AppServices~ {
        +setContext(contextCreator) ApplicationServicesInitializer
    }

    class StartedApplication~ContextType~ {
        +ContextType context
    }

    class expiredListingDeletionHandlerCreator {
        +expiredListingDeletionHandlerCreator(applicationServicesHost) TimerHandler
    }

    class ApplicationServicesFactory {
        +forRequest(rawAuthHeader,hints) ApplicationServices
        +forSystemTask() SystemTaskServices
    }

    class TimerHandler {
    }

    class HttpHandler {
    }

    Cellix ..|> AzureFunctionHandlerRegistry
    Cellix ..|> InfrastructureServiceRegistry
    Cellix ..|> ApplicationServicesInitializer
    Cellix ..|> ContextBuilder

    AzureFunctionHandlerRegistry --> PendingHandler
    AzureFunctionHandlerRegistry --> PendingTimerHandler

    Cellix --> RequestScopedHost
    Cellix --> AppHost

    expiredListingDeletionHandlerCreator --> TimerHandler
    expiredListingDeletionHandlerCreator --> ApplicationServicesFactory

    ApplicationServicesFactory ..|> AppHost

    PendingHandler --> HttpHandler
    PendingTimerHandler --> TimerHandler
Loading

File-Level Changes

Change Details Files
Extend Cellix bootstrap to support registering Azure timer handlers and wire a new expired-listing deletion timer into the API startup pipeline.
  • Introduce TimerHandler support in cellix.ts, including types, pending timer handler queue, and registration in startUp via app.timer
  • Add registerAzureFunctionTimerHandler API to AzureFunctionHandlerRegistry with phase checks, and store timer handlers similarly to HTTP handlers
  • Update apps/api/src/index.ts to resolve blob storage and listing deletion configuration from infrastructure context and register the new processExpiredListingDeletions timer with NCRONTAB 0 0 2 * * *
apps/api/src/cellix.ts
apps/api/src/index.ts
Add a timer handler that drives the expired listing deletion workflow using system-level application services and OpenTelemetry tracing.
  • Implement expiredListingDeletionHandlerCreator that uses ApplicationServicesFactory.forSystemTask() to obtain Listing.ItemListing.processExpiredDeletions and logs/traces execution, including error handling and span attributes
  • Prepare testing scaffold for the timer handler (OpenTelemetry and context logging)
apps/api/src/timers/expired-listing-deletion-handler.ts
apps/api/src/timers/expired-listing-deletion-handler.test.ts
Introduce an application-level workflow to process expired listings, deleting listing entities, related conversations, and listing images with partial failure handling.
  • Add ListingDeletionConfig to context-spec and expose blobStorageService and listingDeletionConfig on ApiContextSpec, sourced from env vars in API bootstrap
  • Extend Listing and ItemListing application services to accept dependencies (dataSources, blobStorage, listingDeletionConfig) and expose processExpiredDeletions on ItemListing
  • Implement processExpiredDeletions service that queries expired listings via ItemListingReadRepo.getExpiredForDeletion, deletes associated blobs in chunks, calls delete-by-listing for conversations, and marks listings as deleted via domain unit-of-work with structured logging and error aggregation
  • Add BDD-style feature and tests for process-expired-deletions covering no-op, success, multi-item, blob failures, listing failures, and absence of blob storage
packages/sthrift/context-spec/src/index.ts
packages/sthrift/context-spec/package.json
packages/sthrift/application-services/src/contexts/listing/index.ts
packages/sthrift/application-services/src/contexts/listing/item/index.ts
packages/sthrift/application-services/src/contexts/listing/item/process-expired-deletions.ts
packages/sthrift/application-services/src/contexts/listing/item/process-expired-deletions.test.ts
packages/sthrift/application-services/src/contexts/listing/item/features/process-expired-deletions.feature
apps/api/src/index.ts
Add a conversation cleanup application service that deletes all conversations for a given listing with per-conversation error isolation.
  • Implement deleteByListing application service that reads conversations by listingId from ConversationReadRepo, then for each runs a scoped transaction to requestDelete and save the conversation, accumulating successes and errors
  • Expose deleteByListing on ConversationApplicationService and wire it into the Conversation context
  • Add BDD-style feature and tests for delete-by-listing covering empty, single, multiple, and partial-failure scenarios
packages/sthrift/application-services/src/contexts/conversation/conversation/delete-by-listing.ts
packages/sthrift/application-services/src/contexts/conversation/conversation/delete-by-listing.test.ts
packages/sthrift/application-services/src/contexts/conversation/conversation/features/delete-by-listing.feature
packages/sthrift/application-services/src/contexts/conversation/conversation/index.ts
Extend domain and infrastructure blob storage to support deleting blobs used by listing images.
  • Add deleteBlob(containerName, blobPath) to Domain BlobStorage service interface
  • Implement deleteBlob in ServiceBlobStorage using @azure/storage-blob BlobServiceClient, initialized in startUp from AZURE_STORAGE_CONNECTION_STRING
  • Add @azure/storage-blob as a dependency for the blob storage service package
packages/sthrift/domain/src/domain/services/blob-storage.ts
packages/sthrift/service-blob-storage/src/index.ts
packages/sthrift/service-blob-storage/package.json
pnpm-lock.yaml
Expose a system-scoped application services host for background tasks like timers.
  • Introduce SystemTaskServices type containing Listing and Conversation context application services
  • Extend ApplicationServicesFactory (app-services host) to provide forSystemTask() returning services built with dataSourcesFactory.withSystemPassport(), blobStorageService, and listingDeletionConfig
  • Update GraphQL and REST handler creators to take a generic AppServicesHost instead of the previous factory type to keep types consistent with the new host interface
packages/sthrift/application-services/src/index.ts
packages/sthrift/graphql/src/init/handler.ts
packages/sthrift/rest/src/index.ts
Add persistence APIs for finding expired listings and conversations by listing ID, including safer ObjectId handling and expanded test coverage.
  • Extend ItemListingReadRepository with getExpiredForDeletion(archivalMonths, limit?) that filters by state in ['Expired','Cancelled'] and updatedAt < cutoffDate, with validation and logging for archivalMonths
  • Extend ConversationReadRepository with getByListingId(listingId) fetching conversations for a listing, and harden getByUser and getBySharerReserverListing to validate ObjectIds upfront and log detailed errors
  • Update corresponding BDD feature files and tests to cover getExpiredForDeletion scenarios (results, empty, limit) and getByListingId scenarios (results, none, empty string, invalid ObjectId) plus improved query mocking
  • Refactor conversation and reservation-request read-repository tests to use a reusable createQueryChain helper for mocking thenable Mongoose query chains and adjust imports accordingly
packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts
packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.test.ts
packages/sthrift/persistence/src/datasources/readonly/listing/item/features/item-listing.read-repository.feature
packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.ts
packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts
packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/features/conversation.read-repository.feature
packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.test.ts
Extend Conversation domain aggregate with a delete operation guarded by permissions, used by the cleanup workflow.
  • Add requestDelete() to Conversation aggregate, enforcing canManageConversation permission and toggling isDeleted if not already set
packages/sthrift/domain/src/domain/contexts/conversation/conversation/conversation.ts
Wire new services and timers into existing infrastructure and security/tracing lifecycle, plus minor test/query refactors.
  • Expose blobStorageService and listingDeletionConfig on ApiContextSpec and construct them in API bootstrap from infrastructure services and env vars
  • Ensure Cellix shutdown and service start/stop tracing code is formatted and logs service lifecycle, with no functional change aside from timer registration support
  • Adjust imports and minor formatting/lint fixes in various test files and infrastructure code
packages/sthrift/context-spec/src/index.ts
apps/api/src/index.ts
apps/api/src/cellix.ts
packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts
packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.test.ts
.snyk

Assessment against linked issues

Issue Objective Addressed Explanation
#200 Implement logic and scheduling to automatically delete expired listings from the operational database after a 6‑month archival period.
#200 Ensure deletion of expired listings also removes associated data (e.g., conversations/messages and listing images) and is observable via logs/telemetry.
#200 Update system documentation to clearly describe listing lifecycle, retention rules, and the new expired-listing deletion behavior. The PR only changes code and tests (application services, repositories, timer handler, blob storage service) and does not modify any documentation files such as the referenced BRD/SRD documents or other docs describing lifecycle/retention.

Possibly linked issues

  • #Listing - Expired Deletion: PR fully implements scheduled deletion of expired listings after 6 months, removing blobs/conversations and adding telemetry and config.
  • #unknown: PR implements the scheduled 6‑month deletion of cancelled listings and related blobs/messages required by the issue.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In expired-listing-deletion-handler.ts you’re downcasting the AppServicesHost<ApplicationServices> to ApplicationServicesFactory to call forSystemTask; it would be safer to type the handler creator parameter as ApplicationServicesFactory (or extend the host interface) so you don’t rely on an unchecked cast at runtime.
  • ConversationReadRepository.getByListingId catches all errors and returns an empty array, which will also hide unexpected persistence issues; consider narrowing the try/catch to just ObjectId construction or rethrowing non-ObjectId errors so operational problems don’t appear as “no conversations found”.
  • process-expired-deletions.ts hardcodes both ARCHIVAL_MONTHS, BATCH_SIZE, and the blob container name (listing-images); if these are likely to change per environment or over time, it may be worth routing them through configuration rather than constants to avoid future code changes for operational tuning.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `expired-listing-deletion-handler.ts` you’re downcasting the `AppServicesHost<ApplicationServices>` to `ApplicationServicesFactory` to call `forSystemTask`; it would be safer to type the handler creator parameter as `ApplicationServicesFactory` (or extend the host interface) so you don’t rely on an unchecked cast at runtime.
- `ConversationReadRepository.getByListingId` catches all errors and returns an empty array, which will also hide unexpected persistence issues; consider narrowing the try/catch to just `ObjectId` construction or rethrowing non-`ObjectId` errors so operational problems don’t appear as “no conversations found”.
- `process-expired-deletions.ts` hardcodes both `ARCHIVAL_MONTHS`, `BATCH_SIZE`, and the blob container name (`listing-images`); if these are likely to change per environment or over time, it may be worth routing them through configuration rather than constants to avoid future code changes for operational tuning.

## Individual Comments

### Comment 1
<location> `apps/api/src/timers/expired-listing-deletion-handler.ts:7-8` </location>
<code_context>
+
+const tracer = trace.getTracer('timer:expired-listing-deletion');
+
+export const expiredListingDeletionHandlerCreator = (
+	applicationServicesHost: AppServicesHost<ApplicationServices>,
+): TimerHandler => {
+	return async (timer, context) => {
</code_context>

<issue_to_address>
**issue (bug_risk):** Tighten the handler creator parameter type instead of casting to `ApplicationServicesFactory` at runtime.

This function accepts `AppServicesHost<ApplicationServices>` but immediately casts it to `ApplicationServicesFactory` to call `forSystemTask()`, which will throw if a different `AppServicesHost` is passed. Please either change the parameter (and registration) to `ApplicationServicesFactory`, or add a runtime check before casting (e.g. verifying `"forSystemTask" in applicationServicesHost`) and handle the failure path explicitly so the type matches the actual usage.
</issue_to_address>

### Comment 2
<location> `apps/api/src/cellix.ts:315-321` </location>
<code_context>
 		return this;
 	}

+	public registerAzureFunctionTimerHandler(
+		name: string,
+		schedule: string,
+		handlerCreator: (
+			applicationServicesHost: RequestScopedHost<AppServices, unknown>,
+		) => TimerHandler,
+	): AzureFunctionHandlerRegistry<ContextType, AppServices> {
+		this.ensurePhase('app-services', 'handlers');
+		this.pendingTimerHandlers.push({ name, schedule, handlerCreator });
</code_context>

<issue_to_address>
**issue (bug_risk):** The implementation of `registerAzureFunctionTimerHandler` uses a different `handlerCreator` type than the interface declaration.

This type mismatch means `Cellix` no longer correctly implements `AzureFunctionHandlerRegistry` and will either fail type-checking or rely on unsafe casts. Please either change the implementation to use `AppHost<AppServices>` or update the interface to the intended type so the signatures stay aligned.
</issue_to_address>

### Comment 3
<location> `packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.ts:149-158` </location>
<code_context>
 		}
 	}
+
+	async getByListingId(
+		listingId: string,
+		options?: FindOptions,
+	): Promise<
+		Domain.Contexts.Conversation.Conversation.ConversationEntityReference[]
+	> {
+		if (!listingId || listingId.trim() === '') {
+			return [];
+		}
+
+		try {
+			const result = await this.mongoDataSource.find(
+				{ listing: new MongooseSeedwork.ObjectId(listingId) },
+				{ ...options, populateFields: populateFields },
+			);
+			return result.map((doc) => this.converter.toDomain(doc, this.passport));
+		} catch (error) {
+			console.warn('Error with ObjectId in getByListingId:', error);
+			return [];
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Swallowing all `getByListingId` errors and returning an empty array may hide real failures.

Because every error is caught and converted to `[]`, callers can’t tell whether there are truly no conversations or the query failed, which obscures real production issues.

Consider limiting the `try`/`catch` to just `ObjectId` creation, or rethrowing unexpected errors while only translating known `ObjectId` errors into `[]`. Another option is to return a typed error or result wrapper so callers can distinguish “not found” from “query failed.”
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@kerry-linn kerry-linn marked this pull request as draft January 7, 2026 22:11
- Remove export from ListingDependencies interface (only used internally)
- Fix type safety in expired-listing-deletion-handler by using ApplicationServicesFactory directly instead of casting from AppServicesHost
Use AppHost<AppServices> type alias in both registerAzureFunctionHttpHandler
and registerAzureFunctionTimerHandler implementations instead of expanding to
RequestScopedHost<AppServices, unknown> for consistency with interface declarations
@kerry-linn
Copy link
Contributor Author

@sourcery-ai review

@kerry-linn kerry-linn self-assigned this Jan 8, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • The archival period, batch size, and blob container name in process-expired-deletions.ts are hard-coded constants; consider sourcing these from configuration (env or context spec) so they can be adjusted without code changes and differ per environment.
  • In ConversationReadRepository.getByListingId, all errors are caught and converted to an empty array with a console.warn; consider narrowing the try/catch to ObjectId construction or using the shared logging mechanism so unexpected persistence errors are not silently downgraded.
  • The deletion pipeline assumes all listing images reside in a single listing-images container; if different listing types or environments may use different containers, it would be safer to pass the container name along with each image or derive it from configuration instead of hard-coding it.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The archival period, batch size, and blob container name in `process-expired-deletions.ts` are hard-coded constants; consider sourcing these from configuration (env or context spec) so they can be adjusted without code changes and differ per environment.
- In `ConversationReadRepository.getByListingId`, all errors are caught and converted to an empty array with a `console.warn`; consider narrowing the try/catch to ObjectId construction or using the shared logging mechanism so unexpected persistence errors are not silently downgraded.
- The deletion pipeline assumes all listing images reside in a single `listing-images` container; if different listing types or environments may use different containers, it would be safer to pass the container name along with each image or derive it from configuration instead of hard-coding it.

## Individual Comments

### Comment 1
<location> `apps/api/src/timers/expired-listing-deletion-handler.ts:7-8` </location>
<code_context>
+
+const tracer = trace.getTracer('timer:expired-listing-deletion');
+
+export const expiredListingDeletionHandlerCreator = (
+	applicationServicesFactory: ApplicationServicesFactory,
+): TimerHandler => {
+	return async (timer, context) => {
</code_context>

<issue_to_address>
**issue (bug_risk):** The timer handler expects an ApplicationServicesFactory, but the Cellix timer registration passes an AppHost, which likely causes a type and runtime mismatch.

In `Cellix.registerAzureFunctionTimerHandler`, `handlerCreator` is currently typed as `(applicationServicesHost: AppHost<AppServices>) => TimerHandler` and is called with `this.appServicesHostInternal`, but `expiredListingDeletionHandlerCreator` is defined as `(applicationServicesFactory: ApplicationServicesFactory) => TimerHandler`. Unless `AppHost<ApplicationServices>` exposes `forSystemTask`, this will fail type-checking and `forSystemTask` will be undefined at runtime. Please either:
- Change `registerAzureFunctionTimerHandler` to pass an `ApplicationServicesFactory`, or
- Update `expiredListingDeletionHandlerCreator` to accept an `AppHost` and obtain `forSystemTask` (or equivalent) via another mechanism.

As written, the function signatures are incompatible and will likely break at runtime.
</issue_to_address>

### Comment 2
<location> `packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts:433` </location>
<code_context>
+				}),
+			];
+			// Update mock model to return new conversations
+			const createMockQuery = (queryResult: unknown) => {
+				const mockQuery = {
+					lean: vi.fn(),
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting shared mocking helpers and centralising cleanup so the new listing scenarios focus on behaviour rather than repeated query/mocking boilerplate.

You can keep the new coverage but reduce complexity and duplication by centralising the mocking patterns and separating framework plumbing from the scenarios.

### 1. Reuse a single query-chain helper instead of inline `createMockQuery`

Right now `createMockQuery` is re‑implemented inside the `Given` for `"listing-1"`. That makes the scenario harder to scan and duplicates a tricky mocking pattern.

Extract a single helper at the top of the file (or reuse the existing `createQueryChain` if compatible) and reuse it for all `find` mocks:

```ts
// near other helpers in this file
const createFindQueryChain = <T>(queryResult: T) => {
	const mockQuery = {
		lean: vi.fn(),
		populate: vi.fn(),
		exec: vi.fn().mockResolvedValue(queryResult),
		catch: vi.fn((onReject) => Promise.resolve(queryResult).catch(onReject)),
	};
	mockQuery.lean.mockReturnValue(mockQuery);
	mockQuery.populate.mockReturnValue(mockQuery);
	// biome-ignore lint/suspicious/noThenProperty: Intentional thenable mock for Mongoose queries
	Object.defineProperty(mockQuery, 'then', {
		value: vi.fn((onResolve) => Promise.resolve(queryResult).then(onResolve)),
		enumerable: false,
		configurable: true,
	});
	return mockQuery;
};
```

Then the scenario becomes declarative:

```ts
Given('a Conversation document with listing "listing-1"', () => {
	mockConversations = [
		makeMockConversation({
			listing: makeMockListing('listing-1'),
		}),
	];

	mockModel.find = vi
		.fn(() => createFindQueryChain(mockConversations)) as unknown as typeof mockModel.find;
});
```

### 2. Centralise `mockModel.find` setup in a helper

Multiple scenarios mutate `mockModel.find` inline (`listing-1`, no conversations, etc.). A small helper keeps each scenario readable and avoids repeating the cast:

```ts
// near other helpers
const setFindResult = <T>(result: T) => {
	mockModel.find = vi
		.fn(() => createFindQueryChain(result)) as unknown as typeof mockModel.find;
};
```

Usage:

```ts
Given('a Conversation document with listing "listing-1"', () => {
	mockConversations = [
		makeMockConversation({
			listing: makeMockListing('listing-1'),
		}),
	];
	setFindResult(mockConversations);
});

When('I call getByListingId with "listing-without-conversations"', async () => {
	setFindResult([]);
	result = await repository.getByListingId(createValidObjectId('listing-without-conversations'));
});
```

This keeps the scenarios focused on domain behaviour instead of mocking details.

### 3. Move `vi.restoreAllMocks()` out of `When` steps

Mixing test cleanup into the `When` steps couples business behaviour with framework lifecycle. You can keep the same safety by moving `restoreAllMocks` to a shared `afterEach`/`afterAll`:

```ts
// inside the relevant describe/suite for this feature
afterEach(() => {
	vi.restoreAllMocks();
});
```

Then remove the inline calls:

```ts
When('I call getBySharerReserverListing with invalid ID', async () => {
	result = await repository.getBySharerReserverListing(
		'invalid-id',
		createValidObjectId('reserver'),
		createValidObjectId('listing'),
	);
});

When('I call getByListingId with invalid ID', async () => {
	result = await repository.getByListingId('invalid-id');
});
```

This preserves all existing behaviour but makes each scenario easier to read and reason about: steps describe only conversation/listing behaviour, while mocking and cleanup are handled in small, reusable helpers.
</issue_to_address>

### Comment 3
<location> `packages/sthrift/application-services/src/contexts/listing/item/process-expired-deletions.test.ts:84` </location>
<code_context>
+				}),
+			};
+
+			mockDataSources = {
+				domainDataSource: {
+					Conversation: {
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting helper factories for data sources and blob storage so each scenario builds its own mocks instead of mutating shared state in Background.

You can keep all behaviour but reduce complexity by extracting small helpers and narrowing shared mutable state. For example:

### 1. Extract a factory for data sources

Instead of rebuilding the full `mockDataSources` shape inline and mutating `mockExpiredListings`/`mockConversations`, push those into a helper that returns fresh mocks per scenario:

```ts
function createDataSources({
  expiredListings = [],
  conversations = [],
  shouldFailListingDeletion = false,
}: {
  expiredListings?: Array<{ id: string; images: string[] }>;
  conversations?: Array<{ id: string }>;
  shouldFailListingDeletion?: boolean;
}): {
  dataSources: DataSources;
  listingRepo: { get: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
  conversationRepo: { get: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
} {
  const listingRepo = {
    get: vi.fn().mockImplementation(() => ({ requestDelete: vi.fn() })),
    save: vi.fn().mockResolvedValue(undefined),
  };

  const conversationRepo = {
    get: vi.fn().mockImplementation(() => ({ requestDelete: vi.fn() })),
    save: vi.fn().mockResolvedValue(undefined),
  };

  const listingUow = {
    withScopedTransaction: vi.fn((callback: (repo: typeof listingRepo) => unknown) => {
      if (shouldFailListingDeletion) {
        return Promise.reject(new Error('Failed to delete listing'));
      }
      return Promise.resolve(callback(listingRepo));
    }),
  };

  const conversationUow = {
    withScopedTransaction: vi.fn((callback: (repo: typeof conversationRepo) => unknown) =>
      Promise.resolve(callback(conversationRepo)),
    ),
  };

  const dataSources = {
    domainDataSource: {
      Listing: {
        ItemListing: {
          ItemListingUnitOfWork: listingUow,
        },
      },
      Conversation: {
        Conversation: {
          ConversationUnitOfWork: conversationUow,
        },
      },
    },
    readonlyDataSource: {
      Listing: {
        ItemListing: {
          ItemListingReadRepo: {
            getExpiredForDeletion: vi.fn().mockResolvedValue(expiredListings),
          },
        },
      },
      Conversation: {
        Conversation: {
          ConversationReadRepo: {
            getByListingId: vi.fn().mockResolvedValue(conversations),
          },
        },
      },
    },
  } as unknown as DataSources;

  return { dataSources, listingRepo, conversationRepo };
}
```

Then scenarios configure their own data sources instead of mutating shared vars:

```ts
Scenario('Successfully deleting multiple expired listings', ({ When, Then }) => {
  const expiredListings = [
    { id: 'listing-1', images: [] },
    { id: 'listing-2', images: [] },
    { id: 'listing-3', images: [] },
  ];
  const { dataSources } = createDataSources({ expiredListings });

  When('the expired deletion process runs', async () => {
    const processFunction = processExpiredDeletions(dataSources, mockBlobStorage);
    result = await processFunction();
  });

  Then('the result should show 3 deleted listings', () => {
    expect(result.deletedCount).toBe(3);
  });
});
```

This removes `mockExpiredListings`, `mockConversations`, `mockListingUow`, `mockConversationUow`, `shouldFailListingDeletion` from the Background and keeps the unit-of-work behaviour in one place.

### 2. Extract a blob storage mock helper

Similarly, encapsulate the call‑count / failure behaviour:

```ts
function createBlobStorageMock({
  failFirstDelete = false,
}: {
  failFirstDelete?: boolean;
} = {}): {
  blobStorage: Domain.Services['BlobStorage'];
  deleteBlobSpy: ReturnType<typeof vi.fn>;
} {
  let callCount = 0;
  const deleteBlobSpy = vi.fn().mockImplementation(() => {
    callCount++;
    if (failFirstDelete && callCount === 1) {
      return Promise.reject(new Error('Blob delete failed'));
    }
    return Promise.resolve();
  });

  const blobStorage = { deleteBlob: deleteBlobSpy } as unknown as Domain.Services['BlobStorage'];

  return { blobStorage, deleteBlobSpy };
}
```

Then the Background only needs high-level knobs:

```ts
Background(({ Given }) => {
  Given('the blob storage service is available', () => {
    ({ blobStorage: mockBlobStorage, deleteBlobSpy } = createBlobStorageMock());
  });
});
```

And a scenario that needs failure configures it locally instead of mutating `shouldFailBlobDeletion`:

```ts
Scenario('Handling image deletion failure gracefully', ({ When, Then }) => {
  const expiredListings = [{ id: 'listing-1', images: ['image1.jpg', 'image2.jpg'] }];
  const { dataSources } = createDataSources({ expiredListings });
  ({ blobStorage: mockBlobStorage, deleteBlobSpy } = createBlobStorageMock({ failFirstDelete: true }));

  When('the expired deletion process runs', async () => {
    const processFunction = processExpiredDeletions(dataSources, mockBlobStorage);
    result = await processFunction();
  });

  Then('the result should show 1 deleted listings', () => {
    expect(result.deletedCount).toBe(1);
  });
});
```

### 3. Narrow Background to “environment”, not per‑scenario state

With these helpers, Background can be reduced to static setup (feature file wiring, maybe default blob storage) and any truly global state. Each scenario then:

- Builds its own `expiredListings`, `conversations` data.
- Calls `createDataSources` / `createBlobStorageMock` with explicit options.
- Avoids mutating shared arrays/flags, reducing coupling and making each scenario self-contained.
</issue_to_address>

### Comment 4
<location> `packages/sthrift/application-services/src/contexts/conversation/conversation/delete-by-listing.test.ts:36` </location>
<code_context>
+		errors: Array<{ conversationId: string; error: string }>;
+	};
+
+	Background(({ Given, And }) => {
+		Given('the data sources are available', () => {
+			shouldFailFirstConversation = false;
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting a reusable test harness factory so each scenario configures its own conversations and failure behavior instead of relying on shared mutable state in the Background block.

You can reduce the shared mutable complexity in the `Background` by extracting a small harness factory and letting each `Scenario` build its own configuration, instead of mutating `mockConversations` and `shouldFailFirstConversation` and relying on an internal `callCount`.

For example, pull the wiring of `mockDataSources`, `callCount`, and the failure flag into a helper:

```ts
function createDeleteByListingHarness({
  conversations,
  failFirstConversation = false,
}: {
  conversations: Array<{ id: string }>;
  failFirstConversation?: boolean;
}) {
  let callCount = 0;

  const mockConversationRepo = {
    get: vi.fn().mockImplementation(() => ({ requestDelete: vi.fn() })),
    save: vi.fn().mockResolvedValue(undefined),
  };

  const mockConversationUow = {
    withScopedTransaction: vi.fn(async (callback: any) => {
      callCount++;
      if (failFirstConversation && callCount === 1) {
        throw new Error('Failed to delete conversation');
      }
      return callback(mockConversationRepo);
    }),
  };

  const mockDataSources = {
    domainDataSource: {
      Conversation: {
        Conversation: {
          ConversationUnitOfWork: mockConversationUow,
        },
      },
    },
    readonlyDataSource: {
      Conversation: {
        Conversation: {
          ConversationReadRepo: {
            getByListingId: vi.fn().mockResolvedValue(conversations),
          },
        },
      },
    },
  } as unknown as DataSources;

  return {
    deleteFn: deleteByListing(mockDataSources),
    mockConversationRepo,
    mockConversationUow,
  };
}
```

Then each scenario can express its setup explicitly without touching shared state:

```ts
Scenario('Successfully deleting a single conversation for a listing', ({ When, Then }) => {
  const { deleteFn } = createDeleteByListingHarness({
    conversations: [{ id: 'conv-1' }],
  });

  let result: Awaited<ReturnType<typeof deleteFn>>;

  When('conversations are deleted for listing id {string}', async () => {
    result = await deleteFn('listing-single-conv');
  });

  Then('the result should show 1 deleted conversations', () => {
    expect(result.deletedCount).toBe(1);
    expect(result.deletedConversationIds).toContain('conv-1');
  });
});
```

And for the failure case:

```ts
const { deleteFn } = createDeleteByListingHarness({
  conversations: [{ id: 'conv-1' }, { id: 'conv-2' }],
  failFirstConversation: true,
});
```

This keeps all current behaviour (including the “fail the first transaction” semantics) but removes:

- The global `mockConversations` and `shouldFailFirstConversation` mutations.
- The need to mentally track `callCount` in each scenario.
- The large `Background` block that every scenario must understand before reading the actual test logic.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

- Use mockResolvedValue instead of mockImplementation where appropriate
- Ensure Promise.resolve wraps callback returns for consistency
- Keep existing test patterns while improving readability
…uration

- Add ListingDeletionConfig interface to @sthrift/context-spec for archivalMonths, batchSize, and blobContainerName
- Thread configuration from environment variables through ApiContextSpec to application services
- Update process-expired-deletions.ts to accept config parameter instead of hard-coded constants
- Improve error handling in ConversationReadRepository:
  * Narrow try/catch scope to ObjectId construction only
  * Add structured error logging with context (listingId, userId, etc.)
  * Replace console.warn with console.error for better visibility
- Update all tests to pass mock configuration
- Add generic type parameter to registerAzureFunctionTimerHandler for flexible factory typing

Resolves Sourcery feedback: archival period, batch size, and blob container now configurable per environment
@kerry-linn
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In processExpiredDeletions, all listings and their images are processed sequentially; consider parallelizing per listing (or at least image deletion via Promise.allSettled) to keep the daily timer run performant when batchSize is large.
  • processExpiredDeletions currently only records listing-level failures in errors, while conversation deletion failures are logged and discarded; if these errors are operationally relevant, consider propagating them into the result (e.g., by attaching conversation-level errors to the listing entry) so callers can see the full failure picture.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `processExpiredDeletions`, all listings and their images are processed sequentially; consider parallelizing per listing (or at least image deletion via `Promise.allSettled`) to keep the daily timer run performant when `batchSize` is large.
- `processExpiredDeletions` currently only records listing-level failures in `errors`, while conversation deletion failures are logged and discarded; if these errors are operationally relevant, consider propagating them into the result (e.g., by attaching conversation-level errors to the listing entry) so callers can see the full failure picture.

## Individual Comments

### Comment 1
<location> `packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts:200-203` </location>
<code_context>
 		}
 	}
+
+	async getExpiredForDeletion(
+		archivalMonths: number,
+		limit = 100,
+	): Promise<Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]> {
+		const cutoffDate = new Date();
+		cutoffDate.setMonth(cutoffDate.getMonth() - archivalMonths);
</code_context>

<issue_to_address>
**issue:** Validate `archivalMonths` to avoid unexpected date calculation when misconfigured

This relies on `archivalMonths` being a positive integer, but since it comes from config/env it could be `0`, negative, or `NaN`, which would make the `setMonth` call compute an incorrect cutoff and potentially delete the wrong items. Add validation (e.g., throw or early-return when `archivalMonths <= 0` or `!Number.isFinite(archivalMonths)`).
</issue_to_address>

### Comment 2
<location> `apps/api/src/cellix.ts:121` </location>
<code_context>
+     *
+     * @throws Error - If called before application services are initialized.
+     */
+	registerAzureFunctionTimerHandler<TFactory extends RequestScopedHost<AppServices, unknown> = AppHost<AppServices>>(
+		name: string,
+		schedule: string,
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the timer registration API and unifying pending handler wiring so the lifecycle and types mirror the HTTP path and are easier to follow.

You can keep the new timer feature but simplify the types and lifecycle wiring to reduce cognitive load.

### 1. Drop the unused generic + cast on `registerAzureFunctionTimerHandler`

The generic `TFactory` is immediately erased via a cast when pushing into `pendingTimerHandlers`, so it doesn’t buy type-safety but does add complexity.

You can align the timer registration API with the HTTP one and avoid the assertion:

```ts
// Before
public registerAzureFunctionTimerHandler<
  TFactory extends RequestScopedHost<AppServices, unknown> = AppHost<AppServices>
>(
  name: string,
  schedule: string,
  handlerCreator: (
    applicationServicesFactory: TFactory,
  ) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingTimerHandlers.push({
    name,
    schedule,
    handlerCreator: handlerCreator as (
      host: RequestScopedHost<AppServices, unknown>
    ) => TimerHandler
  });
  this.phase = 'handlers';
  return this;
}
```

```ts
// After – simpler, consistent with HTTP
public registerAzureFunctionTimerHandler(
  name: string,
  schedule: string,
  handlerCreator: (
    applicationServicesHost: AppHost<AppServices>,
  ) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingTimerHandlers.push({ name, schedule, handlerCreator });
  this.phase = 'handlers';
  return this;
}
```

And simplify `PendingTimerHandler` accordingly:

```ts
interface PendingTimerHandler<AppServices> {
  name: string;
  schedule: string;
  handlerCreator: (applicationServicesHost: AppHost<AppServices>) => TimerHandler;
}
```

This keeps the same runtime behavior while making the API symmetric with HTTP and removing the need for the cast and explanatory comment.

---

### 2. Unify pending handler representation and startup wiring

You can remove the separate `PendingTimerHandler` type and `pendingTimerHandlers` list by using a discriminated union. This also lets you consolidate the duplicated registration loops in `setupLifecycle()`.

```ts
// Replace PendingHandler + PendingTimerHandler with:
type PendingEndpoint<AppServices> =
  | {
      kind: 'http';
      name: string;
      options: Omit<HttpFunctionOptions, 'handler'>;
      handlerCreator: (host: AppHost<AppServices>) => HttpHandler;
    }
  | {
      kind: 'timer';
      name: string;
      schedule: string;
      handlerCreator: (host: AppHost<AppServices>) => TimerHandler;
    };

// Single queue
private readonly pendingEndpoints: Array<PendingEndpoint<AppServices>> = [];
```

Update registration:

```ts
public registerAzureFunctionHttpHandler(
  name: string,
  options: Omit<HttpFunctionOptions, 'handler'>,
  handlerCreator: (applicationServicesHost: AppHost<AppServices>) => HttpHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingEndpoints.push({
    kind: 'http',
    name,
    options,
    handlerCreator,
  });
  this.phase = 'handlers';
  return this;
}

public registerAzureFunctionTimerHandler(
  name: string,
  schedule: string,
  handlerCreator: (applicationServicesHost: AppHost<AppServices>) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingEndpoints.push({
    kind: 'timer',
    name,
    schedule,
    handlerCreator,
  });
  this.phase = 'handlers';
  return this;
}
```

And consolidate `setupLifecycle()` wiring:

```ts
private setupLifecycle(): void {
  for (const e of this.pendingEndpoints) {
    if (e.kind === 'http') {
      app.http(e.name, {
        ...e.options,
        handler: (request, context) => {
          if (!this.appServicesHostInternal) {
            throw new Error('Application not started yet');
          }
          return e.handlerCreator(this.appServicesHostInternal)(request, context);
        },
      });
    } else {
      app.timer(e.name, {
        schedule: e.schedule,
        handler: (timer, context) => {
          if (!this.appServicesHostInternal) {
            throw new Error('Application not started yet');
          }
          return e.handlerCreator(this.appServicesHostInternal)(timer, context);
        },
      });
    }
  }

  // existing app.hook.appStart(...)
}
```

This keeps all existing functionality (including phase behavior and lazy binding to `appServicesHostInternal`) while:

- Removing duplicated interfaces/queues.
- Removing the duplicated startup loops.
- Keeping the registration and lifecycle wiring easier to follow.
</issue_to_address>

### Comment 3
<location> `packages/sthrift/application-services/src/contexts/listing/item/index.ts:68` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
 }

 export const ItemListing = (
-	dataSources: DataSources,
+	deps: DataSources | ItemListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the ItemListing factory by overloading to a single dependency shape and extracting the default deletion config constant to avoid repeated narrowing and inline branching.

You can keep the new functionality and backward compatibility while reducing the runtime/type complexity by normalizing to a single dependency shape and extracting the default config.

### 1. Normalize to `ItemListingDependencies` via overloads

Instead of threading `'in'` checks throughout the implementation, use overloads and normalize once:

```ts
export interface ItemListingDependencies {
  dataSources: DataSources;
  blobStorage?: Domain.Services['BlobStorage'];
  listingDeletionConfig?: ListingDeletionConfig;
}

export function ItemListing(
  dataSources: DataSources,
): ItemListingApplicationService;
export function ItemListing(
  deps: ItemListingDependencies,
): ItemListingApplicationService;
export function ItemListing(
  arg: DataSources | ItemListingDependencies,
): ItemListingApplicationService {
  const deps: ItemListingDependencies =
    'dataSources' in arg ? arg : { dataSources: arg };

  const deletionConfig = deps.listingDeletionConfig ?? DEFAULT_DELETION_CONFIG;

  return {
    create: create(deps.dataSources),
    queryById: queryById(deps.dataSources),
    queryBySharer: queryBySharer(deps.dataSources),
    queryAll: queryAll(deps.dataSources),
    cancel: cancel(deps.dataSources),
    update: update(deps.dataSources),
    deleteListings: deleteListings(deps.dataSources),
    unblock: unblock(deps.dataSources),
    queryPaged: queryPaged(deps.dataSources),
    processExpiredDeletions: processExpiredDeletions(
      deps.dataSources,
      deletionConfig,
      deps.blobStorage,
    ),
  };
}
```

Callers can still pass `DataSources` directly, but the implementation only deals with `ItemListingDependencies`, which removes repeated narrowing and makes the factory easier to scan.

### 2. Extract the default deletion config

Pull the default into a constant so the control flow around config is clearer and reuse is trivial:

```ts
const DEFAULT_DELETION_CONFIG: ListingDeletionConfig = {
  archivalMonths: 6,
  batchSize: 100,
  blobContainerName: 'listing-images',
};
```

Then in the factory (as above):

```ts
const deletionConfig =
  deps.listingDeletionConfig ?? DEFAULT_DELETION_CONFIG;
```

This keeps the behavior identical (including tests/backwards compatibility), but reduces inline branching and makes the configuration story easier to understand at a glance.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@kerry-linn
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The Cellix handler APIs are now asymmetric (HTTP handlers take an AppHost while timer handlers use a generic RequestScopedHost) and expiredListingDeletionHandlerCreator expects an ApplicationServicesFactory with forSystemTask; it would be clearer and less error‑prone to standardize these types so that the object passed into all handler creators consistently exposes forRequest/forSystemTask (e.g., use ApplicationServicesFactory for timers and HTTP, or wrap AppHost appropriately).
  • The Mongoose query mocking helpers (createNullPopulateChain, the inline createMockQuery in conversation tests, and createQueryChain in listing tests) are very similar but duplicated; consider extracting a single reusable helper to reduce repetition and make future adjustments to the query shape or thenable behavior in one place.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The Cellix handler APIs are now asymmetric (HTTP handlers take an AppHost while timer handlers use a generic RequestScopedHost) and `expiredListingDeletionHandlerCreator` expects an `ApplicationServicesFactory` with `forSystemTask`; it would be clearer and less error‑prone to standardize these types so that the object passed into all handler creators consistently exposes `forRequest`/`forSystemTask` (e.g., use `ApplicationServicesFactory` for timers and HTTP, or wrap `AppHost` appropriately).
- The Mongoose query mocking helpers (`createNullPopulateChain`, the inline `createMockQuery` in conversation tests, and `createQueryChain` in listing tests) are very similar but duplicated; consider extracting a single reusable helper to reduce repetition and make future adjustments to the query shape or thenable behavior in one place.

## Individual Comments

### Comment 1
<location> `apps/api/src/cellix.ts:315-324` </location>
<code_context>
 		return this;
 	}

+	public registerAzureFunctionTimerHandler<TFactory extends RequestScopedHost<AppServices, unknown> = AppHost<AppServices>>(
+		name: string,
+		schedule: string,
+		handlerCreator: (
+			applicationServicesFactory: TFactory,
+		) => TimerHandler,
+	): AzureFunctionHandlerRegistry<ContextType, AppServices> {
+		this.ensurePhase('app-services', 'handlers');
+		// Type assertion is safe here because TFactory extends RequestScopedHost<AppServices, unknown>
+		// and the actual runtime value passed will be compatible with whatever TFactory the caller specified
+		this.pendingTimerHandlers.push({ 
+			name, 
+			schedule, 
</code_context>

<issue_to_address>
**suggestion (bug_risk):** The generic and cast in registerAzureFunctionTimerHandler add complexity and rely on an unsafe assumption.

`registerAzureFunctionTimerHandler` introduces `TFactory` but then casts `handlerCreator` to `(host: RequestScopedHost<...>) => TimerHandler` when storing it, and the registry is always called with `this.appServicesHostInternal`. Since only a single concrete host type is used in practice, you could either:

- Remove the generic and take `handlerCreator: (host: RequestScopedHost<AppServices, unknown>) => TimerHandler`, or
- Wrap `handlerCreator` at the call site into a `(host: RequestScopedHost<...>) => TimerHandler` instead of casting.

This removes the unsafe cast and keeps the timer registration API simpler and safer.

Suggested implementation:

```typescript
	public registerAzureFunctionTimerHandler(
		name: string,
		schedule: string,
		handlerCreator: (
			applicationServicesHost: RequestScopedHost<AppServices, unknown>,
		) => TimerHandler,
	): AzureFunctionHandlerRegistry<ContextType, AppServices> {
		this.ensurePhase('app-services', 'handlers');
		this.pendingTimerHandlers.push({
			name,
			schedule,
			handlerCreator,
		});
		this.phase = 'handlers';
		return this;
	}

```

You will need to update all call sites of `registerAzureFunctionTimerHandler` to remove the generic type argument (if any) and accept a `RequestScopedHost<AppServices, unknown>` parameter in their `handlerCreator` function. If any callers relied on a more specific factory type (e.g., `AppHost<AppServices>`), they can keep their parameter typed as that specific type and rely on TypeScript’s structural typing, or explicitly annotate it as `RequestScopedHost<AppServices, unknown>`.
</issue_to_address>

### Comment 2
<location> `packages/sthrift/application-services/src/contexts/listing/item/index.ts:68` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
 }

 export const ItemListing = (
-	dataSources: DataSources,
+	deps: DataSources | ItemListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the ItemListing factory to use overloads plus a dependency-normalization helper so the wiring logic stays simple and type-safe while preserving backward compatibility.

You can keep backward compatibility while simplifying the factory’s control flow by:

1. Using overloads instead of a union in the implementation type.
2. Centralizing dependency normalization (including defaults) into a helper.

That keeps `ItemListing` as a simple wiring function and makes types easier to follow.

Example refactor:

```ts
export interface ItemListingDependencies {
	dataSources: DataSources;
	blobStorage?: Domain.Services['BlobStorage'];
	listingDeletionConfig?: ListingDeletionConfig;
}

const defaultDeletionConfig: ListingDeletionConfig = {
	archivalMonths: 6,
	batchSize: 100,
	blobContainerName: 'listing-images',
};

function normalizeDeps(
	deps: DataSources | ItemListingDependencies,
): ItemListingDependencies {
	if ('dataSources' in deps) {
		return {
			...deps,
			listingDeletionConfig: deps.listingDeletionConfig ?? defaultDeletionConfig,
		};
	}

	return {
		dataSources: deps,
		listingDeletionConfig: defaultDeletionConfig,
	};
}

// Overloads for better typing at call sites
export function ItemListing(dataSources: DataSources): ItemListingApplicationService;
export function ItemListing(deps: ItemListingDependencies): ItemListingApplicationService;

// Single implementation
export function ItemListing(
	deps: DataSources | ItemListingDependencies,
): ItemListingApplicationService {
	const { dataSources, blobStorage, listingDeletionConfig } = normalizeDeps(deps);

	return {
		create: create(dataSources),
		queryById: queryById(dataSources),
		queryBySharer: queryBySharer(dataSources),
		queryAll: queryAll(dataSources),
		cancel: cancel(dataSources),
		update: update(dataSources),
		deleteListings: deleteListings(dataSources),
		unblock: unblock(dataSources),
		queryPaged: queryPaged(dataSources),
		processExpiredDeletions: processExpiredDeletions(
			dataSources,
			listingDeletionConfig!,
			blobStorage,
		),
	};
}
```

This keeps:

- Both construction styles (`DataSources` only and full `ItemListingDependencies`).
- Defaulting logic in one place (`normalizeDeps`), which can be reused by the higher-level `Listing` factory to avoid repeating the union-discrimination and defaulting logic there.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@kerry-linn
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 4 issues, and left some high level feedback:

  • In expired-listing-deletion-handler, the applicationServicesHost is typed as a generic RequestScopedHost but is cast to ApplicationServicesFactory to access forSystemTask; consider tightening the Cellix/timer registration types so the handler creator receives an ApplicationServicesFactory (or a host that explicitly exposes forSystemTask) and avoid the runtime cast.
  • The deleteByListing application service runs a separate withScopedTransaction per conversation, which can become expensive for listings with many conversations; consider wrapping all deletions for a listing in a single unit-of-work/transaction where your domain and infrastructure allow it.
  • The processExpiredDeletions loop deletes listings, conversations, and blobs fully sequentially; if you expect large batches, you might want to introduce bounded concurrency (e.g., per-listing or per-blob) to reduce total run time while still keeping per-listing error isolation.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `expired-listing-deletion-handler`, the `applicationServicesHost` is typed as a generic `RequestScopedHost` but is cast to `ApplicationServicesFactory` to access `forSystemTask`; consider tightening the Cellix/timer registration types so the handler creator receives an `ApplicationServicesFactory` (or a host that explicitly exposes `forSystemTask`) and avoid the runtime cast.
- The `deleteByListing` application service runs a separate `withScopedTransaction` per conversation, which can become expensive for listings with many conversations; consider wrapping all deletions for a listing in a single unit-of-work/transaction where your domain and infrastructure allow it.
- The `processExpiredDeletions` loop deletes listings, conversations, and blobs fully sequentially; if you expect large batches, you might want to introduce bounded concurrency (e.g., per-listing or per-blob) to reduce total run time while still keeping per-listing error isolation.

## Individual Comments

### Comment 1
<location> `apps/api/src/timers/expired-listing-deletion-handler.ts:8-12` </location>
<code_context>
+
+const tracer = trace.getTracer('timer:expired-listing-deletion');
+
+export const expiredListingDeletionHandlerCreator = (
+	applicationServicesHost: RequestScopedHost<ApplicationServices, unknown>,
+): TimerHandler => {
+	return async (timer, context) => {
+		await tracer.startActiveSpan('processExpiredListingDeletions', async (span) => {
+			try {
+				context.log('ExpiredListingDeletion: Timer triggered');
</code_context>

<issue_to_address>
**issue (bug_risk):** Timer handler type forces an unsafe cast to `ApplicationServicesFactory`

The handler argument is declared as `RequestScopedHost<ApplicationServices, unknown>`, but then cast to `ApplicationServicesFactory` to call `forSystemTask()`. This hides the real requirement behind an unsafe cast. Please change the parameter type to something that natively exposes `forSystemTask` (e.g. `ApplicationServicesFactory` or a small interface with `forRequest` + `forSystemTask`) so the compiler enforces correct usage and the cast is unnecessary.
</issue_to_address>

### Comment 2
<location> `packages/sthrift/persistence/src/datasources/readonly/conversation/conversation/conversation.read-repository.test.ts:50` </location>
<code_context>
 }

 function createNullPopulateChain<T>(result: T) {
-	const innerLean = { lean: vi.fn(async () => result) };
-	const innerPopulate = { populate: vi.fn(() => innerLean) };
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting a single reusable query-mocking helper to replace the multiple bespoke mock builders added in these tests.

You can reduce the added complexity by unifying the query mocking into a single, minimal helper and reusing it across scenarios.

### 1. Replace `createNullPopulateChain` with a generic helper

Keep the thenable + `exec` behavior, but centralize it so you don’t need to redefine `createMockQuery` inline:

```ts
function createQueryChain<T>(result: T) {
	const mockQuery = {
		lean: vi.fn(),
		populate: vi.fn(),
		exec: vi.fn().mockResolvedValue(result),
		catch: vi.fn((onReject) => Promise.resolve(result).catch(onReject)),
	};

	mockQuery.lean.mockReturnValue(mockQuery);
	mockQuery.populate.mockReturnValue(mockQuery);

	// biome-ignore lint/suspicious/noThenProperty: Intentional thenable mock for Mongoose queries
	Object.defineProperty(mockQuery, 'then', {
		value: vi.fn((onResolve, onReject) =>
			Promise.resolve(result).then(onResolve, onReject)
		),
		enumerable: false,
		configurable: true,
	});

	return mockQuery;
}
```

Then use it everywhere:

```ts
// old
mockModel.findOne = vi.fn(() => createNullPopulateChain(mockConversation)) as unknown as typeof mockModel.findOne;

// new
mockModel.findOne = vi.fn(() => createQueryChain(mockConversation)) as unknown as typeof mockModel.findOne;

// old
mockModel.find = vi.fn(() => createNullPopulateChain([])) as unknown as typeof mockModel.find;

// new
mockModel.find = vi.fn(() => createQueryChain([])) as unknown as typeof mockModel.find;
```

And in the “Getting conversations by listing ID” scenario, drop the inline `createMockQuery` and reuse the helper:

```ts
Given('a Conversation document with listing "listing-1"', () => {
	mockConversations = [
		makeMockConversation({
			listing: makeMockListing('listing-1'),
		}),
	];

	mockModel.find = vi.fn(
		() => createQueryChain(mockConversations)
	) as unknown as typeof mockModel.find;
});
```

This keeps all existing behavior (thenable, `exec`, `populate`, `lean`, `catch`) but avoids duplicated mocking logic and makes each test easier to read.
</issue_to_address>

### Comment 3
<location> `packages/sthrift/application-services/src/contexts/listing/item/index.ts:68` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
 }

 export const ItemListing = (
-	dataSources: DataSources,
+	deps: DataSources | ItemListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the union-type handling and default configuration into a small `normalizeItemListingDeps` helper so the `ItemListing` factory stays focused on simple wiring.

You can keep the backward-compatible union while isolating the shape‑normalization and defaulting logic in a tiny helper. That removes the runtime `'in'` checks from the main factory and makes the wiring easier to read.

For example:

```ts
type ItemListingDepsInput = DataSources | ItemListingDependencies;

const normalizeItemListingDeps = (
	deps: ItemListingDepsInput,
): ItemListingDependencies => {
	if ('dataSources' in deps) {
		return {
			dataSources: deps.dataSources,
			blobStorage: deps.blobStorage,
			listingDeletionConfig: deps.listingDeletionConfig ?? {
				archivalMonths: 6,
				batchSize: 100,
				blobContainerName: 'listing-images',
			},
		};
	}

	// Backwards-compatible DataSources-only call
	return {
		dataSources: deps,
		listingDeletionConfig: {
			archivalMonths: 6,
			batchSize: 100,
			blobContainerName: 'listing-images',
		},
	};
};
```

Then the factory becomes focused on wiring:

```ts
export const ItemListing = (
	deps: DataSources | ItemListingDependencies,
): ItemListingApplicationService => {
	const { dataSources, blobStorage, listingDeletionConfig } =
		normalizeItemListingDeps(deps);

	return {
		create: create(dataSources),
		queryById: queryById(dataSources),
		queryBySharer: queryBySharer(dataSources),
		queryAll: queryAll(dataSources),
		cancel: cancel(dataSources),
		update: update(dataSources),
		deleteListings: deleteListings(dataSources),
		unblock: unblock(dataSources),
		queryPaged: queryPaged(dataSources),
		processExpiredDeletions: processExpiredDeletions(
			dataSources,
			listingDeletionConfig,
			blobStorage,
		),
	};
};
```

This keeps all existing behavior and call sites intact but localizes the union-type introspection and default config to a single, easily testable function.
</issue_to_address>

### Comment 4
<location> `packages/sthrift/application-services/src/contexts/listing/index.ts:20` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
+}
+
 export const Listing = (
-	dataSources: DataSources,
+	deps: DataSources | ListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the dependency-union handling into a dedicated `normalizeListingDeps` helper so that `Listing` remains a thin, easily readable wrapper.

You can hide the union-handling and repackaging behind a small helper to keep `Listing` as a simple thin wrapper.

```ts
interface ListingDependencies {
  dataSources: DataSources;
  blobStorage?: Domain.Services['BlobStorage'];
  listingDeletionConfig?: ListingDeletionConfig;
}

const normalizeListingDeps = (
  deps: DataSources | ListingDependencies,
): ItemListingDependencies => {
  const dataSources = 'dataSources' in deps ? deps.dataSources : deps;
  const blobStorage = 'blobStorage' in deps ? deps.blobStorage : undefined;
  const listingDeletionConfig =
    'listingDeletionConfig' in deps ? deps.listingDeletionConfig : undefined;

  const itemListingDeps: ItemListingDependencies = { dataSources };
  if (blobStorage) itemListingDeps.blobStorage = blobStorage;
  if (listingDeletionConfig) {
    itemListingDeps.listingDeletionConfig = listingDeletionConfig;
  }
  return itemListingDeps;
};

export const Listing = (
  deps: DataSources | ListingDependencies,
): ListingContextApplicationService => {
  const itemListingDeps = normalizeListingDeps(deps);

  return {
    ItemListing: ItemListingApi(itemListingDeps),
  };
};
```

This keeps all current behavior (including the union input type and optional fields) but makes `Listing` itself easy to scan: it just normalizes deps and forwards them. The complexity of handling different shapes of `deps` is isolated and testable in `normalizeListingDeps`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@kerry-linn
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 5 issues, and left some high level feedback:

  • In conversation.read-repository.test.ts the Given/When block around getBySharerReserverListing has a misplaced }); and When(...) on the same line, which will make the scenario hard to read and potentially brittle; please fix the formatting so each step is clearly separated and scoped.
  • In process-expired-deletions.ts, the comment mentions 'bounded concurrency' for image deletion but the implementation fires all deleteBlob calls in parallel with Promise.allSettled; either implement an actual concurrency limit or adjust the comment to reflect the current behavior to avoid confusion for future maintainers.
  • The timer registration path (registerAzureFunctionTimerHandlerexpired-listing-deletion-handler) currently relies on casting the handler creator to RequestScopedHost and using ApplicationServicesFactory as the argument type; consider tightening these types (e.g., basing both on RequestScopedHost<AppServices, unknown>) to remove the cast and make the expected shape of the host explicit at compile time.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `conversation.read-repository.test.ts` the `Given`/`When` block around `getBySharerReserverListing` has a misplaced `});` and `When(...)` on the same line, which will make the scenario hard to read and potentially brittle; please fix the formatting so each step is clearly separated and scoped.
- In `process-expired-deletions.ts`, the comment mentions 'bounded concurrency' for image deletion but the implementation fires all `deleteBlob` calls in parallel with `Promise.allSettled`; either implement an actual concurrency limit or adjust the comment to reflect the current behavior to avoid confusion for future maintainers.
- The timer registration path (`registerAzureFunctionTimerHandler``expired-listing-deletion-handler`) currently relies on casting the handler creator to `RequestScopedHost` and using `ApplicationServicesFactory` as the argument type; consider tightening these types (e.g., basing both on `RequestScopedHost<AppServices, unknown>`) to remove the cast and make the expected shape of the host explicit at compile time.

## Individual Comments

### Comment 1
<location> `apps/api/src/cellix.ts:192-195` </location>
<code_context>
 	handlerCreator: (applicationServicesHost: AppHost<AppServices>) => HttpHandler;
 }

+interface PendingTimerHandler<AppServices> {
+	name: string;
+	schedule: string;
+	handlerCreator: (applicationServicesHost: RequestScopedHost<AppServices, unknown>) => TimerHandler;
+}
+
</code_context>

<issue_to_address>
**suggestion:** The timer handler typing and casting hide the `forSystemTask` requirement and weaken type-safety.

`PendingTimerHandler` stores `handlerCreator` as `(host: RequestScopedHost<AppServices, unknown>) => TimerHandler`, and `registerAzureFunctionTimerHandler` casts any handler factory to that type. But `expiredListingDeletionHandlerCreator` expects an `ApplicationServicesFactory` with `.forSystemTask()`, which `RequestScopedHost` doesn’t guarantee. The cast hides this mismatch and can allow invalid factories that only fail at runtime.

To keep this type-safe, either:
- Define a `TimerAppServicesHost` that extends `RequestScopedHost` with `forSystemTask` and use it in the timer handler typing, or
- Make `PendingTimerHandler` generic over `TFactory` (as with `registerAzureFunctionHttpHandler`) and carry that type instead of downcasting.

This ensures handlers that need `forSystemTask` are correctly constrained by the type system.

Suggested implementation:

```typescript
export type RequestScopedHost<S, H = unknown> = {
  forRequest(rawAuthHeader?: string, hints?: H): Promise<S>;
};

export type TimerAppServicesHost<AppServices, H = unknown> =
  RequestScopedHost<AppServices, H> & {
    /**
     * Returns application services configured for background/system tasks
     * where there is no end-user request context.
     */
    forSystemTask(): Promise<AppServices>;
  };

```

```typescript
interface PendingTimerHandler<AppServices> {
	name: string;
	schedule: string;
	handlerCreator: (applicationServicesHost: TimerAppServicesHost<AppServices>) => TimerHandler;
}

```

To fully apply the type-safe `forSystemTask` constraint, you should also:

1. Update the signature of `registerAzureFunctionTimerHandler` (or equivalent) so its `handlerCreator` parameter and any stored timer handler metadata use `TimerAppServicesHost<AppServices>` instead of `RequestScopedHost<AppServices, unknown>`, e.g.:
   - From:  
     `(handlerCreator: (host: RequestScopedHost<AppServices, unknown>) => TimerHandler) => ...`
   - To:  
     `(handlerCreator: (host: TimerAppServicesHost<AppServices>) => TimerHandler) => ...`

2. Remove any casts that currently coerce a more specific factory (e.g. one requiring `.forSystemTask()`) to `(host: RequestScopedHost<AppServices, unknown>) => TimerHandler`. They should become unnecessary once all timer-related types use `TimerAppServicesHost`.

3. Ensure that whatever object you pass to `handlerCreator` when a timer fires actually implements `TimerAppServicesHost<AppServices>`:
   - It must already satisfy `RequestScopedHost<AppServices, H>`.
   - It must also define `forSystemTask(): Promise<AppServices>`.
   If there is an existing `ApplicationServicesFactory`/host that has `forSystemTask`, it is likely the right implementation type to wire in here.

4. If there are other helper types or registries that currently reference `PendingTimerHandler<AppServices>` or the old `(RequestScopedHost<...>) => TimerHandler` signature, update them to propagate `TimerAppServicesHost<AppServices>` so the type-safety is enforced end-to-end.
</issue_to_address>

### Comment 2
<location> `packages/sthrift/application-services/src/contexts/listing/item/process-expired-deletions.ts:16-25` </location>
<code_context>
+	errors: Array<{ listingId: string; error: string }>;
+}
+
+async function deleteListingImages(
+	blobStorage: BlobStorageService,
+	images: string[],
+	containerName: string,
+): Promise<number> {
+	let deletedCount = 0;
+	// Process images concurrently with bounded concurrency
+	const imagePromises = images.map((imagePath) =>
+		blobStorage
+			.deleteBlob(containerName, imagePath)
+			.then(() => ({ success: true as const, imagePath }))
+			.catch((error) => ({
+				success: false as const,
+				imagePath,
+				error:
+					error instanceof Error ? error.message : String(error),
+			})),
+	);
+
+	const results = await Promise.allSettled(imagePromises);
+	for (const result of results) {
+		if (result.status === 'fulfilled') {
</code_context>

<issue_to_address>
**suggestion:** The image deletion helper both double-wraps errors and doesn’t actually implement bounded concurrency as the comment suggests.

Because the `catch` is handled inside the `images.map`, each promise resolves to `{ success, imagePath, error? }` and never rejects, so `Promise.allSettled` will only ever yield `'fulfilled'` entries; the `'rejected'` branch can’t be hit.

The “bounded concurrency” note in the comment is also inaccurate: `Promise.allSettled(imagePromises)` fires all deletions simultaneously.

To align behavior with the current structure, you can await the promises directly and branch on `success`:

```ts
const results = await Promise.all(imagePromises);
for (const r of results) {
  if (r.success) {
    deletedCount++;
  } else {
    console.warn(
      `[ExpiredDeletion] Failed to delete image ${r.imagePath}: ${r.error}`,
    );
  }
}
```

If true bounded concurrency is desired (e.g. to avoid overloading storage on large batches), consider using a concurrency limiter like `p-limit` and update the comment to match the actual behavior.
</issue_to_address>

### Comment 3
<location> `apps/api/src/cellix.ts:223` </location>
<code_context>
 	private readonly tracer: Tracer;
 	private readonly servicesInternal: Map<ServiceKey<ServiceBase>, ServiceBase> = new Map();
 	private readonly pendingHandlers: Array<PendingHandler<AppServices>> = [];
+	private readonly pendingTimerHandlers: Array<PendingTimerHandler<AppServices>> = [];
 	private serviceInitializedInternal = false;
 	private phase: Phase = 'infrastructure';
</code_context>

<issue_to_address>
**issue (complexity):** Consider unifying HTTP and timer function registration into a single abstraction and simplifying the timer factory type to keep the lifecycle coherent and avoid unnecessary generic complexity.

You’ve effectively introduced a second, parallel abstraction for timers. You can keep all behavior and reduce complexity by unifying the pending registrations and simplifying the factory type.

### 1. Unify HTTP and timer registrations

Instead of `PendingHandler` + `PendingTimerHandler` + two arrays + two loops, use a single discriminated union:

```ts
type PendingFunction<AppServices> =
  | {
      kind: 'http';
      name: string;
      options: Omit<HttpFunctionOptions, 'handler'>;
      handlerCreator: (host: AppHost<AppServices>) => HttpHandler;
    }
  | {
      kind: 'timer';
      name: string;
      schedule: string;
      handlerCreator: (host: AppHost<AppServices>) => TimerHandler;
    };
```

Then replace:

```ts
private readonly pendingHandlers: Array<PendingHandler<AppServices>> = [];
private readonly pendingTimerHandlers: Array<PendingTimerHandler<AppServices>> = [];
```

with:

```ts
private readonly pendingFunctions: Array<PendingFunction<AppServices>> = [];
```

And change the registration methods to push into a single list:

```ts
public registerAzureFunctionHttpHandler(
  name: string,
  options: Omit<HttpFunctionOptions, 'handler'>,
  handlerCreator: (host: AppHost<AppServices>) => HttpHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingFunctions.push({ kind: 'http', name, options, handlerCreator });
  this.phase = 'handlers';
  return this;
}

public registerAzureFunctionTimerHandler(
  name: string,
  schedule: string,
  handlerCreator: (host: AppHost<AppServices>) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingFunctions.push({ kind: 'timer', name, schedule, handlerCreator });
  this.phase = 'handlers';
  return this;
}
```

And finally, collapse the two loops in `setupLifecycle` into one:

```ts
private setupLifecycle(): void {
  for (const f of this.pendingFunctions) {
    if (!this.appServicesHostInternal) {
      throw new Error('Application not started yet');
    }

    switch (f.kind) {
      case 'http':
        app.http(f.name, {
          ...f.options,
          handler: (request, context) =>
            f.handlerCreator(this.appServicesHostInternal!)(request, context),
        });
        break;

      case 'timer':
        app.timer(f.name, {
          schedule: f.schedule,
          handler: (timer, context) =>
            f.handlerCreator(this.appServicesHostInternal!)(timer, context),
        });
        break;
    }
  }

  // existing app.hook.appStart / app.hook.appTerminate ...
}
```

This keeps all existing behavior but removes duplicated structures and keeps the lifecycle for all functions in one coherent place.

### 2. Simplify the timer factory type

The generic `TFactory extends RequestScopedHost<AppServices, unknown> = AppHost<AppServices>` plus the cast back to `RequestScopedHost` suggests the generic isn’t providing real flexibility.

Unless you have a concrete use case that genuinely needs a narrower `RequestScopedHost` subtype here, you can simplify and avoid the runtime cast:

```ts
public registerAzureFunctionTimerHandler(
  name: string,
  schedule: string,
  handlerCreator: (host: AppHost<AppServices>) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingFunctions.push({ kind: 'timer', name, schedule, handlerCreator });
  this.phase = 'handlers';
  return this;
}
```

This aligns the timer handler factory with the HTTP handler factory (both take `AppHost<AppServices>`), and keeps `RequestScopedHost` as an implementation detail of the hosting model rather than part of the timer API surface.
</issue_to_address>

### Comment 4
<location> `packages/sthrift/application-services/src/contexts/listing/item/index.ts:68` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
 }

 export const ItemListing = (
-	dataSources: DataSources,
+	deps: DataSources | ItemListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the ItemListing factory by using a single dependency object and delegating config defaulting to processExpiredDeletions (or a helper) instead of handling both concerns inline.

You can reduce the new complexity by separating “backwards-compatibility wiring” from the core factory and by centralizing the config defaulting logic.

### 1. Keep `ItemListing` focused on a single dependency shape

Instead of overloading the factory with `DataSources | ItemListingDependencies` and doing runtime discrimination, keep the factory typed to one dependency object and put the adapter near the composition root:

```ts
// item-listing.ts
export interface ItemListingDependencies {
  dataSources: DataSources;
  blobStorage?: Domain.Services['BlobStorage'];
  listingDeletionConfig?: ListingDeletionConfig;
}

export const ItemListing = (
  deps: ItemListingDependencies,
): ItemListingApplicationService => {
  const { dataSources, blobStorage, listingDeletionConfig } = deps;

  return {
    create: create(dataSources),
    queryById: queryById(dataSources),
    queryBySharer: queryBySharer(dataSources),
    queryAll: queryAll(dataSources),
    cancel: cancel(dataSources),
    update: update(dataSources),
    deleteListings: deleteListings(dataSources),
    unblock: unblock(dataSources),
    queryPaged: queryPaged(dataSources),
    processExpiredDeletions: processExpiredDeletions(
      dataSources,
      listingDeletionConfig,
      blobStorage,
    ),
  };
}
```

Then add a tiny adapter for legacy callers that only have `DataSources`:

```ts
// composition-root / adapter
export const ItemListingFromDataSources = (dataSources: DataSources) =>
  ItemListing({ dataSources });
```

Existing code can switch to `ItemListingFromDataSources(dataSources)` with no behavior change, and the core factory stays simple and type-safe.

### 2. Move deletion defaults out of the factory

Instead of computing defaults inside `ItemListing`, let `processExpiredDeletions` (or a helper it calls) handle the defaulting:

```ts
// process-expired-deletions.ts
const withDefaultDeletionConfig = (
  config?: ListingDeletionConfig,
): ListingDeletionConfig => ({
  archivalMonths: 6,
  batchSize: 100,
  blobContainerName: 'listing-images',
  ...config,
});

export const processExpiredDeletions = (
  dataSources: DataSources,
  config: ListingDeletionConfig | undefined,
  blobStorage?: Domain.Services['BlobStorage'],
): ProcessExpiredDeletionsResult => {
  const effectiveConfig = withDefaultDeletionConfig(config);
  // ... existing logic using effectiveConfig and blobStorage
};
```

This keeps `ItemListing` responsible only for wiring dependencies, not for mixing dependency resolution and configuration policy, while preserving the current behavior (defaults + optional overrides).
</issue_to_address>

### Comment 5
<location> `packages/sthrift/application-services/src/contexts/listing/index.ts:20` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
+}
+
 export const Listing = (
-	dataSources: DataSources,
+	deps: DataSources | ListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the dependency shape branching into a small `normalizeListingDeps` helper so the `Listing` factory only does straightforward wiring.

The main complexity comes from `Listing` doing both overload discrimination and wiring. You can keep the current capabilities but move the branching into a small normalizer so the context factory stays simple.

For example:

```ts
type ListingInput = DataSources | ListingDependencies;

const normalizeListingDeps = (deps: ListingInput): ItemListingDependencies => {
	if ('dataSources' in deps) {
		const { dataSources, blobStorage, listingDeletionConfig } = deps;
		return {
			dataSources,
			blobStorage,
			listingDeletionConfig,
		};
	}

	// Backwards-compatible path: plain DataSources
	return { dataSources: deps };
};
```

Then `Listing` becomes straightforward wiring:

```ts
export const Listing = (
	deps: DataSources | ListingDependencies,
): ListingContextApplicationService => {
	const itemListingDeps = normalizeListingDeps(deps);

	return {
		ItemListing: ItemListingApi(itemListingDeps),
	};
};
```

This keeps the public signature and behavior exactly as-is, but removes the repeated `'in'` checks and object mutation from the main factory, making it easier to read and reason about.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

…-deletions and conversation read repository tests

- Updated comments for clarity in process-expired-deletions.ts
- Adjusted formatting for better readability in conversation.read-repository.test.ts
- Consolidated import statements in conversation.read-repository.test.ts
- Enhanced error handling and test scenarios for missing IDs and invalid ObjectId formats
@kerry-linn
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • In processExpiredDeletions, deleteListingImages performs unbounded parallel blob deletions for each listing; consider adding a simple concurrency limiter (e.g., chunking or a small pool) to avoid overloading storage or hitting throttling limits when many images are present.
  • Timer-driven and repository flows now use a mix of console.log/console.error with structured payloads for errors; you might want to route these through a common logging abstraction (or at least standardize message formats) so operational logs from deletions and read repositories are easier to correlate and filter.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `processExpiredDeletions`, `deleteListingImages` performs unbounded parallel blob deletions for each listing; consider adding a simple concurrency limiter (e.g., chunking or a small pool) to avoid overloading storage or hitting throttling limits when many images are present.
- Timer-driven and repository flows now use a mix of `console.log`/`console.error` with structured payloads for errors; you might want to route these through a common logging abstraction (or at least standardize message formats) so operational logs from deletions and read repositories are easier to correlate and filter.

## Individual Comments

### Comment 1
<location> `packages/sthrift/persistence/src/datasources/readonly/reservation-request/reservation-request/reservation-request.read-repository.test.ts:51-60` </location>
<code_context>
-	const innerPopulate = { populate: vi.fn(() => innerLean) };
-	const outerPopulate = { populate: vi.fn(() => innerPopulate) };
-	return { populate: vi.fn(() => outerPopulate) };
+function createQueryChain<T>(result: T) {
+	const mockQuery = {
+		lean: vi.fn(),
+		populate: vi.fn(),
+		exec: vi.fn().mockResolvedValue(result),
+		catch: vi.fn((onReject) => Promise.resolve(result).catch(onReject)),
+	};
+
+	mockQuery.lean.mockReturnValue(mockQuery);
+	mockQuery.populate.mockReturnValue(mockQuery);
+
+	// biome-ignore lint/suspicious/noThenProperty: Intentional thenable mock for Mongoose queries
+	Object.defineProperty(mockQuery, 'then', {
+		value: vi.fn((onResolve, onReject) =>
+			Promise.resolve(result).then(onResolve, onReject),
+		),
+		enumerable: false,
+		configurable: true,
+	});
+
+	return mockQuery;
 }

</code_context>

<issue_to_address>
**issue (testing):** The new query mock is missing `sort` and `limit`, which may break tests if the repository chains those methods.

The previous `createMockQuery` also mocked `sort` and `limit`, implying the repository calls them on the `find` query. The new `createQueryChain` only mocks `lean`, `populate`, `exec`, and `catch`, so any remaining `find(...).sort(...).limit(...).lean()` usage will now fail with `sort/limit is not a function`.

Please add `sort` and `limit` to this mock (returning `mockQuery` for chaining), unless the repository implementation has definitively stopped using them.
</issue_to_address>

### Comment 2
<location> `apps/api/src/cellix.ts:136` </location>
<code_context>
+	 *
+	 * @throws Error - If called before application services are initialized.
+	 */
+	registerAzureFunctionTimerHandler<
+		TFactory extends RequestScopedHost<
+			AppServices,
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the timer handler typing, extracting shared handler registration helpers, and flattening the tracing loop to reduce type noise and improve readability without changing behaviour.

You can simplify a few spots without changing behaviour:

### 1. Over‑generic `registerAzureFunctionTimerHandler`

`TFactory` is constrained to `RequestScopedHost<AppServices, unknown>` but then cast back to `PendingTimerHandler<AppServices>['handlerCreator']`, and at runtime you always use `this.appServicesHostInternal`. You can drop the generic and align the timer API with HTTP:

```ts
// Keep AppHost alias as-is
type AppHost<AppServices> = RequestScopedHost<AppServices, unknown>;

interface AzureFunctionHandlerRegistry<ContextType = unknown, AppServices = unknown> {
  // ...
  registerAzureFunctionTimerHandler(
    name: string,
    schedule: string,
    handlerCreator: (applicationServicesHost: AppHost<AppServices>) => TimerHandler,
  ): AzureFunctionHandlerRegistry<ContextType, AppServices>;
}
```

```ts
interface PendingTimerHandler<AppServices> {
  name: string;
  schedule: string;
  handlerCreator: (applicationServicesHost: AppHost<AppServices>) => TimerHandler;
}

public registerAzureFunctionTimerHandler(
  name: string,
  schedule: string,
  handlerCreator: (applicationServicesHost: AppHost<AppServices>) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingTimerHandlers.push({ name, schedule, handlerCreator });
  this.phase = 'handlers';
  return this;
}
```

This removes the type erasure comment and cast while keeping the same runtime semantics.

### 2. Parallel HTTP / timer registration

The HTTP and timer registration loops are now almost identical. A small internal helper keeps `setupLifecycle` flatter and makes adding future trigger types easier:

```ts
private registerHttpHandlers(): void {
  for (const h of this.pendingHandlers) {
    app.http(h.name, {
      ...h.options,
      handler: (request, context) => {
        if (!this.appServicesHostInternal) {
          throw new Error('Application not started yet');
        }
        return h.handlerCreator(this.appServicesHostInternal)(request, context);
      },
    });
  }
}

private registerTimerHandlers(): void {
  for (const t of this.pendingTimerHandlers) {
    app.timer(t.name, {
      schedule: t.schedule,
      handler: (timer, context) => {
        if (!this.appServicesHostInternal) {
          throw new Error('Application not started yet');
        }
        return t.handlerCreator(this.appServicesHostInternal)(timer, context);
      },
    });
  }
}

private setupLifecycle(): void {
  this.registerHttpHandlers();
  this.registerTimerHandlers();
  // appStart / appTerminate unchanged...
}
```

You still have explicit hooks per trigger, but the main lifecycle method stays simpler.

### 3. Tracing loop readability

`iterateServicesWithTracing` is now quite dense with inline template strings and name extraction. Precomputing values per service flattens the span callback:

```ts
private async iterateServicesWithTracing(
  operationName: 'start' | 'stop',
  serviceMethod: 'startUp' | 'shutDown',
): Promise<void> {
  const operationFullName = `${operationName.charAt(0).toUpperCase() + operationName.slice(1)}Service`;
  const operationActionPending = operationName === 'start' ? 'starting' : 'stopping';
  const operationActionCompleted = operationName === 'start' ? 'started' : 'stopped';

  await Promise.all(
    Array.from(this.servicesInternal.entries()).map(([ctor, service]) => {
      const ctorName = (ctor as { name?: string }).name ?? 'Service';
      const spanName = `Service ${ctorName} ${operationName}`;
      const startMsg = `${operationFullName}: Service ${ctorName} ${operationActionPending}`;
      const doneMsg = `${operationFullName}: Service ${ctorName} ${operationActionCompleted}`;

      return this.tracer.startActiveSpan(spanName, async (span) => {
        try {
          console.log(startMsg);
          await service[serviceMethod]();
          span.setStatus({ code: SpanStatusCode.OK, message: doneMsg });
          console.log(doneMsg);
        } catch (err) {
          span.setStatus({ code: SpanStatusCode.ERROR });
          if (err instanceof Error) {
            span.recordException(err);
          }
          throw err;
        } finally {
          span.end();
        }
      });
    }),
  );
}
```

Same tracing semantics, but the control flow inside the span callback is easier to scan.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@kerry-linn
Copy link
Contributor Author

@sourcery-ai review

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 6 issues, and left some high level feedback:

  • The createQueryChain helper for mocking Mongoose queries is now duplicated across multiple test files; consider extracting a shared test utility (e.g., in a test helpers module) to avoid repetition and keep the mocking behavior consistent.
  • The timer handler currently depends on ApplicationServicesFactory while HTTP/GraphQL handlers consume the more generic AppServicesHost<ApplicationServices>; it may be worth aligning these so all handlers share the same host abstraction (e.g., exposing forSystemTask() off the generic host type) for consistency and easier future refactoring.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `createQueryChain` helper for mocking Mongoose queries is now duplicated across multiple test files; consider extracting a shared test utility (e.g., in a test helpers module) to avoid repetition and keep the mocking behavior consistent.
- The timer handler currently depends on `ApplicationServicesFactory` while HTTP/GraphQL handlers consume the more generic `AppServicesHost<ApplicationServices>`; it may be worth aligning these so all handlers share the same host abstraction (e.g., exposing `forSystemTask()` off the generic host type) for consistency and easier future refactoring.

## Individual Comments

### Comment 1
<location> `packages/sthrift/persistence/src/datasources/readonly/listing/item/item-listing.read-repository.ts:200-209` </location>
<code_context>
 		}
 	}
+
+	async getExpiredForDeletion(
+		archivalMonths: number,
+		limit = 100,
+	): Promise<Domain.Contexts.Listing.ItemListing.ItemListingEntityReference[]> {
+		// Validate archivalMonths to avoid unexpected date calculation when misconfigured
+		if (!Number.isFinite(archivalMonths) || archivalMonths <= 0) {
+			console.error(
+				'[ItemListingReadRepository] Invalid archivalMonths value:',
+				{
+					archivalMonths,
+					isFinite: Number.isFinite(archivalMonths),
+				},
+			);
+			return [];
+		}
+
+		const cutoffDate = new Date();
+		cutoffDate.setMonth(cutoffDate.getMonth() - archivalMonths);
+
+		const result = await this.mongoDataSource.find(
+			{
+				state: { $in: ['Expired', 'Cancelled'] },
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Validate and sanitize the `limit` parameter before passing it to the data source

`archivalMonths` is validated but `limit` is passed directly to `mongoDataSource.find`. Values like `NaN`, negative numbers, or `Infinity` (e.g., from a bad env var) are accepted by Mongoose with driver-dependent behavior and could result in unbounded queries. Normalize `limit` first (e.g., default on invalid and clamp to a max, such as 100–1000) before passing it to the data source.
</issue_to_address>

### Comment 2
<location> `packages/sthrift/application-services/src/contexts/listing/item/process-expired-deletions.ts:37-48` </location>
<code_context>
+	const CHUNK_SIZE = 5; // Process 5 images at a time to avoid throttling
+	let deletedCount = 0;
+
+	for (let i = 0; i < images.length; i += CHUNK_SIZE) {
+		const chunk = images.slice(i, i + CHUNK_SIZE);
+		const chunkPromises = chunk.map((imagePath) =>
+			blobStorage
+				.deleteBlob(containerName, imagePath)
+				.then(() => ({ success: true as const, imagePath }))
+				.catch((error) => ({
+					success: false as const,
+					imagePath,
+					error: error instanceof Error ? error.message : String(error),
+				})),
+		);
+
+		const results = await Promise.allSettled(chunkPromises);
+		for (const result of results) {
+			if (result.status === 'fulfilled') {
</code_context>

<issue_to_address>
**suggestion:** Avoid wrapping already-caught promises in `Promise.allSettled`, or propagate rejections more explicitly

Because each `deleteBlob` call maps errors into `{ success: false, ... }`, `chunkPromises` never reject and `Promise.allSettled` adds no value—the `'rejected'` branch is dead code.

You can either:
1) Switch to `await Promise.all(chunkPromises)` and remove the `status === 'rejected'` handling; or
2) Remove the inner `.catch`, let deletions reject, and keep `allSettled` to handle actual rejections.

Using a single, consistent pattern will make the error-handling behavior clearer and less surprising.

```suggestion
		const results = await Promise.all(chunkPromises);
		for (const result of results) {
			if (result.success) {
				deletedCount++;
			} else {
				console.warn(
					`[ExpiredDeletion] Failed to delete image ${result.imagePath}: ${result.error}`,
				);
			}
		}
```
</issue_to_address>

### Comment 3
<location> `apps/api/src/cellix.ts:370` </location>
<code_context>
 		return this;
 	}

+	public registerAzureFunctionTimerHandler<
+		TFactory extends RequestScopedHost<
+			AppServices,
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the new timer registration API by using a single concrete host type and a shared helper for host-guarded handler registration instead of generic + duplicated logic.

You can keep all the new functionality but significantly reduce complexity by tightening the types and deduplicating the registration paths.

### 1. Remove the generic + cast from `registerAzureFunctionTimerHandler`

The `TFactory` generic is immediately erased via a cast to `PendingTimerHandler<AppServices>['handlerCreator']`, so it adds complexity without real extra power. You already use `AppHost<AppServices>` for HTTP; you can mirror that for timers.

**Before:**

```ts
registerAzureFunctionTimerHandler<
  TFactory extends RequestScopedHost<AppServices, unknown> = AppHost<AppServices>,
>(
  name: string,
  schedule: string,
  handlerCreator: (applicationServicesHost: TFactory) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingTimerHandlers.push({
    name,
    schedule,
    handlerCreator:
      handlerCreator as PendingTimerHandler<AppServices>['handlerCreator'],
  });
  this.phase = 'handlers';
  return this;
}
```

**After (same functionality, simpler types):**

```ts
public registerAzureFunctionTimerHandler(
  name: string,
  schedule: string,
  handlerCreator: (
    applicationServicesHost: AppHost<AppServices>,
  ) => TimerHandler,
): AzureFunctionHandlerRegistry<ContextType, AppServices> {
  this.ensurePhase('app-services', 'handlers');
  this.pendingTimerHandlers.push({ name, schedule, handlerCreator });
  this.phase = 'handlers';
  return this;
}
```

And adjust `PendingTimerHandler` to match:

```ts
interface PendingTimerHandler<AppServices> {
  name: string;
  schedule: string;
  handlerCreator: (applicationServicesHost: AppHost<AppServices>) => TimerHandler;
}
```

This removes the type-erasure cast and keeps the mental model unified: “a handler creator always takes an `AppHost<AppServices>`”.

---

### 2. Share registration logic for HTTP and Timer handlers

The `setupLifecycle` loops are almost identical. A small private helper keeps the behavior the same but reduces duplication and the “parallel system” feel.

**Before (two separate loops):**

```ts
for (const h of this.pendingHandlers) {
  app.http(h.name, {
    ...h.options,
    handler: (request, context) => {
      if (!this.appServicesHostInternal) {
        throw new Error('Application not started yet');
      }
      return h.handlerCreator(this.appServicesHostInternal)(request, context);
    },
  });
}

for (const t of this.pendingTimerHandlers) {
  app.timer(t.name, {
    schedule: t.schedule,
    handler: (timer, context) => {
      if (!this.appServicesHostInternal) {
        throw new Error('Application not started yet');
      }
      return t.handlerCreator(this.appServicesHostInternal)(timer, context);
    },
  });
}
```

**After (shared helper, same behavior):**

```ts
private withStartedHost<Args extends unknown[], R>(
  fn: (host: AppHost<AppServices>, ...args: Args) => R,
): (...args: Args) => R {
  return (...args: Args) => {
    if (!this.appServicesHostInternal) {
      throw new Error('Application not started yet');
    }
    return fn(this.appServicesHostInternal, ...args);
  };
}

private setupLifecycle(): void {
  for (const h of this.pendingHandlers) {
    app.http(h.name, {
      ...h.options,
      handler: this.withStartedHost((host, request, context) =>
        h.handlerCreator(host)(request, context),
      ),
    });
  }

  for (const t of this.pendingTimerHandlers) {
    app.timer(t.name, {
      schedule: t.schedule,
      handler: this.withStartedHost((host, timer, context) =>
        t.handlerCreator(host)(timer, context),
      ),
    });
  }

  // ... appStart / appTerminate remain unchanged
}
```

This keeps the runtime behavior identical, centralizes the “ensure host is ready” logic, and makes adding future function types (queues, events, etc.) a small extension instead of duplicating boilerplate.
</issue_to_address>

### Comment 4
<location> `packages/sthrift/application-services/src/contexts/listing/item/process-expired-deletions.ts:64` </location>
<code_context>
+	});
+}
+
+export const processExpiredDeletions = (
+	dataSources: DataSources,
+	config: ListingDeletionConfig,
</code_context>

<issue_to_address>
**issue (complexity):** Consider flattening the main function and extracting per-listing and image-deletion helpers to make the control flow and error handling easier to scan and maintain.

You can reduce complexity in a few focused places without changing behavior.

### 1. Flatten `processExpiredDeletions` into a plain async function

The factory style adds an extra level for readers/tests without much benefit. You can keep the same behavior in the timer handler by closing over arguments there instead of here.

```ts
export async function processExpiredDeletions(
  dataSources: DataSources,
  config: ListingDeletionConfig,
  blobStorage?: BlobStorageService,
): Promise<ProcessExpiredDeletionsResult> {
  const result: ProcessExpiredDeletionsResult = {
    deletedCount: 0,
    deletedListingIds: [],
    deletedConversationsCount: 0,
    deletedImagesCount: 0,
    errors: [],
  };

  const expiredListings =
    await dataSources.readonlyDataSource.Listing.ItemListing.ItemListingReadRepo
      .getExpiredForDeletion(config.archivalMonths, config.batchSize);

  if (expiredListings.length === 0) {
    return result;
  }

  const uow =
    dataSources.domainDataSource.Listing.ItemListing.ItemListingUnitOfWork;
  const deleteConversations = deleteConversationsByListing(dataSources);

  for (const listing of expiredListings) {
    const listingResult = await deleteSingleListing(
      listing,
      { blobStorage, config, uow, deleteConversations },
    );
    mergeListingResult(result, listingResult);
  }

  return result;
}
```

And in the timer handler you can keep the existing “factory” behavior if desired:

```ts
// before: const handler = processExpiredDeletions(dataSources, config, blobStorage);
const handler = () => processExpiredDeletions(dataSources, config, blobStorage);
```

### 2. Extract per-listing orchestration

Pulling the per-listing flow into its own helper flattens the main function and localizes try/catch and logging:

```ts
interface SingleListingResult extends ProcessExpiredDeletionsResult {}

async function deleteSingleListing(
  listing: { id: string; images?: string[] },
  deps: {
    blobStorage?: BlobStorageService;
    config: ListingDeletionConfig;
    uow: DataSources['domainDataSource']['Listing']['ItemListing']['ItemListingUnitOfWork'];
    deleteConversations: (listingId: string) => Promise<{ deletedCount: number; errors: unknown[] }>;
  },
): Promise<SingleListingResult> {
  const { blobStorage, config, uow, deleteConversations } = deps;
  const listingId = listing.id;

  const result: SingleListingResult = {
    deletedCount: 0,
    deletedListingIds: [],
    deletedConversationsCount: 0,
    deletedImagesCount: 0,
    errors: [],
  };

  try {
    if (blobStorage && listing.images && listing.images.length > 0) {
      const imagesDeleted = await deleteListingImages(
        blobStorage,
        listing.images,
        config.blobContainerName,
      );
      result.deletedImagesCount += imagesDeleted;
      console.log(`[ExpiredDeletion] Deleted ${imagesDeleted} images for listing ${listingId}`);
    }

    const conversationResult = await deleteConversations(listingId);
    result.deletedConversationsCount += conversationResult.deletedCount;
    if (conversationResult.errors.length > 0) {
      console.warn(
        `[ExpiredDeletion] Errors deleting conversations for listing ${listingId}: ${JSON.stringify(conversationResult.errors)}`,
      );
    }

    await deleteListingById(uow, listingId);
    result.deletedCount++;
    result.deletedListingIds.push(listingId);
    console.log(`[ExpiredDeletion] Deleted listing: ${listingId}`);
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : String(error);
    result.errors.push({ listingId, error: errorMessage });
    console.error(
      `[ExpiredDeletion] Failed to delete listing ${listingId}: ${errorMessage}`,
    );
  }

  return result;
}

function mergeListingResult(
  aggregate: ProcessExpiredDeletionsResult,
  listingResult: ProcessExpiredDeletionsResult,
): void {
  aggregate.deletedCount += listingResult.deletedCount;
  aggregate.deletedListingIds.push(...listingResult.deletedListingIds);
  aggregate.deletedConversationsCount += listingResult.deletedConversationsCount;
  aggregate.deletedImagesCount += listingResult.deletedImagesCount;
  aggregate.errors.push(...listingResult.errors);
}
```

This keeps all the same steps but makes the main flow “fetch -> loop -> merge” much easier to scan.

### 3. Simplify `deleteListingImages`

You can keep chunking but avoid the double-layer wrapping plus `allSettled`, and just log errors in place:

```ts
async function deleteListingImages(
  blobStorage: BlobStorageService,
  images: string[],
  containerName: string,
): Promise<number> {
  const CHUNK_SIZE = 5;
  let deletedCount = 0;

  for (let i = 0; i < images.length; i += CHUNK_SIZE) {
    const chunk = images.slice(i, i + CHUNK_SIZE);

    const results = await Promise.all(
      chunk.map(async (imagePath) => {
        try {
          await blobStorage.deleteBlob(containerName, imagePath);
          return true;
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          console.warn(
            `[ExpiredDeletion] Failed to delete image ${imagePath}: ${message}`,
          );
          return false;
        }
      }),
    );

    deletedCount += results.filter(Boolean).length;
  }

  return deletedCount;
}
```

Same behavior (chunked, per-image logging, success counting) with fewer branches and a simpler result shape.

### 4. Optional: move aggregate logging to caller

Since `ProcessExpiredDeletionsResult` already carries full stats and errors, the final summary log can be moved out to the caller (e.g., the timer handler). That leaves this module focused on behavior, while the trigger decides *how* to log:

```ts
// timer handler
const result = await processExpiredDeletions(dataSources, config, blobStorage);
console.log(
  `[ExpiredDeletion] Completed: ${result.deletedCount} listings deleted, ` +
  `${result.deletedConversationsCount} conversations deleted, ` +
  `${result.deletedImagesCount} images deleted, ${result.errors.length} errors`,
);
```

This reduces side effects in the core use case without changing its external behavior.
</issue_to_address>

### Comment 5
<location> `packages/sthrift/application-services/src/contexts/listing/item/index.ts:68` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
 }

 export const ItemListing = (
-	dataSources: DataSources,
+	deps: DataSources | ItemListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting a dependency normalization helper and a default config constant so the `ItemListing` factory remains simple and focused on wiring behavior.

You can reduce the added complexity by isolating the compatibility shim and default config instead of inlining both in `ItemListing`.

1. Extract a single normalization function for dependencies

Keep the public signature backward-compatible (`DataSources | ItemListingDependencies`), but move the `'dataSources' in deps` checks into a small helper so the factory only works with a single shape:

```ts
export type ItemListingDepsInput = DataSources | ItemListingDependencies;

const normalizeItemListingDeps = (
  deps: ItemListingDepsInput,
): ItemListingDependencies => {
  if ('dataSources' in deps) {
    return {
      dataSources: deps.dataSources,
      blobStorage: deps.blobStorage,
      listingDeletionConfig: deps.listingDeletionConfig,
    };
  }

  // legacy callers passing `DataSources`
  return { dataSources: deps };
};
```

Then `ItemListing` becomes simpler:

```ts
export const ItemListing = (
  deps: ItemListingDepsInput,
): ItemListingApplicationService => {
  const { dataSources, blobStorage, listingDeletionConfig } =
    normalizeItemListingDeps(deps);

  const deletionConfig = listingDeletionConfig ?? DEFAULT_LISTING_DELETION_CONFIG;

  return {
    create: create(dataSources),
    queryById: queryById(dataSources),
    queryBySharer: queryBySharer(dataSources),
    queryAll: queryAll(dataSources),
    cancel: cancel(dataSources),
    update: update(dataSources),
    deleteListings: deleteListings(dataSources),
    unblock: unblock(dataSources),
    queryPaged: queryPaged(dataSources),
    processExpiredDeletions: processExpiredDeletions(
      dataSources,
      deletionConfig,
      blobStorage,
    ),
  };
};
```

2. Extract the default config constant

This keeps the factory easy to skim and makes defaults discoverable:

```ts
const DEFAULT_LISTING_DELETION_CONFIG: ListingDeletionConfig = {
  archivalMonths: 6,
  batchSize: 100,
  blobContainerName: 'listing-images',
};
```

This approach preserves all existing behavior and call sites but confines the union-type complexity and defaulting logic to small, focused helpers.
</issue_to_address>

### Comment 6
<location> `packages/sthrift/application-services/src/contexts/listing/index.ts:20` </location>
<code_context>
+	listingDeletionConfig?: ListingDeletionConfig;
+}
+
 export const Listing = (
-	dataSources: DataSources,
+	deps: DataSources | ListingDependencies,
</code_context>

<issue_to_address>
**issue (complexity):** Consider reusing the existing dependency type and moving the union/normalization logic into a small helper to keep the Listing factory as straightforward wiring code.

You can keep the new feature and reduce complexity by (1) reusing the existing `ItemListingDependencies` type and (2) extracting the union handling into a small helper. That keeps `Listing` as a simple composition module and moves the adapter logic into a dedicated function.

For example:

```ts
// Reuse existing type instead of redefining a parallel one
type ListingDependencies = ItemListingDependencies;

type ListingInput = DataSources | ListingDependencies;

const normalizeListingDeps = (deps: ListingInput): ItemListingDependencies => {
  if ('dataSources' in deps) {
    return deps;
  }
  return { dataSources: deps };
};

export const Listing = (
  deps: ListingInput,
): ListingContextApplicationService => {
  const itemListingDeps = normalizeListingDeps(deps);

  return {
    ItemListing: ItemListingApi(itemListingDeps),
  };
};
```

If you want to make the contract even clearer while still supporting legacy callers, you can add overloads without changing behavior:

```ts
export function Listing(
  deps: DataSources,
): ListingContextApplicationService;
export function Listing(
  deps: ListingDependencies,
): ListingContextApplicationService;
export function Listing(
  deps: ListingInput,
): ListingContextApplicationService {
  const itemListingDeps = normalizeListingDeps(deps);
  return { ItemListing: ItemListingApi(itemListingDeps) };
}
```

This keeps all functionality, but the main factory now reads as a simple wiring function, and the type discrimination/adapter logic is isolated and testable.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

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.

Listing - Expired Deletion

2 participants