services/api/src/modules/media/media-claims.service.js

Overview

This module coordinates atomic media claim and deletion handling for files stored in ImageKit and attached to Listing records. It exists to keep file state consistent when a listing adds media, removes media, or loses media references during concurrent writes. The service works with Listing, MediaFileClaim, and deleteAsset from media.service.js.

It is not an HTTP route by itself. Instead, listings.routes.js imports its helpers to protect media writes and deletions at the listing layer. The code comments and the integration tests show that the module is used to handle race conditions, retries, and shared-file preservation.

[!IMPORTANT] The service treats file deletion as a two-phase operation. A file only reaches ImageKit after possession is confirmed with a holder, and only if no other Listing still references it.

How it works

flowchart TD
    AttachFlow[claimListingMediaFiles]
    DeleteFlow[deleteListingFilesSafely]
    Claims[MediaFileClaim]
    Listings[Listing]
    ImageKit[deleteAsset]
    Refs[referencedElsewhere]
    Acquire[acquireForDeletion]
    Transition[transition]
    Release[releaseListingMediaClaims]

    AttachFlow --> Claims
    AttachFlow --> Refs
    Refs --> Listings
    AttachFlow --> Release

    DeleteFlow --> Acquire
    Acquire --> Claims
    DeleteFlow --> Refs
    Refs --> Listings
    DeleteFlow --> Transition
    Transition --> Claims
    DeleteFlow --> ImageKit

The attachment path creates MediaFileClaim records before a listing write finishes. If the write cannot be trusted, the caller can release the created claims. The deletion path first acquires possession for every file, checks whether any other listing still references those file IDs, and only then calls deleteAsset for files that are exclusive to the current listing.

Key components

{
    "title": "listingImageKitFileIds",
    "content": "[badge:public] Extracts unique ImageKit file IDs from a Listing. It reads media items with provider imagekit and sourceDocuments entries with fileId.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media-claims.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "releaseListingMediaClaims",
    "content": "[badge:public] Removes active MediaFileClaim records for the given listing and file IDs. It never touches deleting or deleted states.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media-claims.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "claimListingMediaFiles",
    "content": "[badge:public] Creates active claims for new file IDs before a Listing write. It returns the claims created by this call and throws MEDIA_IN_USE when a file is already used elsewhere.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media-claims.service.js",
    "badges": [
        "public"
    ]
}
{
    "title": "deleteListingFilesSafely",
    "content": "[badge:public] Deletes exclusive ImageKit files with possession checks and rollback support. It returns deleted and preserved file IDs, and optionally inProgress.",
    "type": "function",
    "filePath": "services/api/src/modules/media/media-claims.service.js",
    "badges": [
        "public"
    ]
}

Data & fields

[
    {
        "name": "listingId",
        "type": "string | ObjectId",
        "required": true,
        "description": "Listing target used by claim and deletion operations."
    },
    {
        "name": "fileIds",
        "type": "string[]",
        "required": true,
        "description": "ImageKit file identifiers processed by the service. Empty values are removed, and duplicates are deduplicated."
    },
    {
        "name": "hooks",
        "type": "object | null",
        "description": "Test-only pause hooks. The service calls delete:afterSharedCheck when present."
    },
    {
        "name": "report",
        "type": "boolean",
        "description": "When true, deleteListingFilesSafely returns inProgress together with deleted and preserved."
    },
    {
        "name": "holder",
        "type": "ObjectId",
        "description": "Operation token used to confirm deletion possession."
    }
]
[
    {
        "name": "MediaFileClaim",
        "type": "object",
        "required": true,
        "description": "MongoDB claim document for one ImageKit file.",
        "children": [
            {
                "name": "_id",
                "type": "string",
                "required": true,
                "description": "Equals the fileId."
            },
            {
                "name": "listingId",
                "type": "ObjectId",
                "required": true,
                "description": "Listing that owns the claim."
            },
            {
                "name": "state",
                "type": "string",
                "required": true,
                "description": "Lifecycle state. The code uses active, deleting, erasing, and deleted."
            },
            {
                "name": "holder",
                "type": "ObjectId",
                "description": "Per-operation possession token used during deletion."
            }
        ]
    }
]
  • media[].provider — only imagekit media are counted by listingImageKitFileIds.
  • media[].fileId — used for claim and cross-listing reference checks.
  • sourceDocuments[].fileId — included in claim, reference, and delete checks.
active -> deleting -> erasing -> deleted

This state chain is the core claim lifecycle described in the comments and enforced by the update logic. The module also uses a temporary holder value to keep one deletion operation from taking another operation’s claim.

Behaviors

  • mediaInUseError — Builds a 409 error with code MEDIA_IN_USE and the message about a file already being used in another publication.
  • step — Runs a named hook when hooks[name] is a function. It exists only for tests.
  • unique — Removes falsy values, stringifies file IDs, and deduplicates them.
  • toObjectId — Converts a value into mongoose.Types.ObjectId.
  • listingImageKitFileIds [badge:public] — Returns unique ImageKit file IDs from media and sourceDocuments.
  • referencedElsewhere — Returns a Set with file IDs still referenced by other Listing documents.
  • releaseListingMediaClaims [badge:public] — Deletes only active claims for the given listing and file IDs.
  • claimListingMediaFiles [badge:public] — Creates active claims for new file IDs, checks cross-listing references, and rolls back created claims on MEDIA_IN_USE.
  • acquireForDeletion — Tries to move or create a claim into deleting with a holder. It returns acquired, already_deleted, in_progress, or held_elsewhere.
  • transition — Updates a claim only when _id, listingId, state, and holder all match. It returns a boolean.
  • deleteListingFilesSafely [badge:public] — Deletes files that belong only to the current listing, preserves shared or contested files, and optionally reports inProgress.

Important implementation details

claimListingMediaFiles first creates claims and then checks for shared references. If any newly claimed file is already referenced elsewhere, it throws MEDIA_IN_USE and releases only the claims created by that call. The duplicate-key branch treats an existing active claim from the same listing as idempotent.

deleteListingFilesSafely uses a two-phase model. First, it acquires claims for all files without deleting anything. Then it checks whether any acquired file is referenced by another listing. After that, it moves each exclusive file from deleting to erasing, calls deleteAsset, and finally writes the tombstone state deleted.

The module also handles partial failures. If deleteAsset fails for some files, it keeps their claims in deleting, collects failure metadata, and throws an aggregate error with code IMAGEKIT_PARTIAL_DELETE_FAILURE.

Workflows

Attachment workflow

  • The caller collects new file IDs.
  • claimListingMediaFiles inserts MediaFileClaim records in active.
  • The function checks whether another listing already references those IDs.
  • If conflict exists, the function releases the newly created claims.
  • The caller continues only after the listing write is safe.

Deletion workflow

  • The caller sends the target listing and the file IDs to delete.
  • deleteListingFilesSafely acquires claims with a private holder.
  • The function checks whether any file is still shared.
  • Shared or contested files are preserved.
  • Exclusive files move to erasing, then deleteAsset runs.
  • Successful deletions end in deleted.
  • Failures revert to deleting for a later retry.

Gotchas & edge cases

[!WARNING] claimListingMediaFiles only releases claims created in the current call when it sees MEDIA_IN_USE. Existing claims from other states are not touched.

[!CAUTION] deleteListingFilesSafely can return inProgress when another operation from the same listing already owns the file. That file is preserved in the result, but its physical deletion is not confirmed.

[!IMPORTANT] MongoDB errors during phase 1 are propagated before any deletion happens. ImageKit errors are accumulated and reported after all files are attempted.

[!NOTE] The hooks argument exists only for tests. The code uses it to pause at delete:afterSharedCheck.

  • services/api/src/modules/listings/listings.routes.js uses claimListingMediaFiles, deleteListingFilesSafely, listingImageKitFileIds, and releaseListingMediaClaims inside listing write and deletion paths.
  • services/api/src/modules/media/media.service.js provides deleteAsset, which performs the physical ImageKit deletion.
  • services/api/src/models/Listing.js is queried to detect whether other listings still reference a file.
  • services/api/src/models/MediaFileClaim.js stores the claim state and holder data used by this module.
  • services/api/integration/mongo/e5-3-b2-claim-possession.mongo.js and services/api/integration/mongo/e6-3-c3-adversarial.mongo.js exercise the possession rules, retries, and blocking states under concurrent conditions.