Skip to content

fix(media): delete the stored file on every delete path and surface storage failures - #3067

Open
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:fix/media-delete-storage-first
Open

fix(media): delete the stored file on every delete path and surface storage failures#3067
swissky wants to merge 2 commits into
emdash-cms:mainfrom
swissky:fix/media-delete-storage-first

Conversation

@swissky

@swissky swissky commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes the first half of #3049: a media delete could report { deleted: true } while the object was never removed from storage, and because the row was already gone nothing could ever find the object again.

  • handleMediaDelete(db, id, storage) now owns storage deletion. EmDashRuntime.handleMediaDelete passes its storage adapter, so the MCP media_delete tool removes the file too (its description already promised that; it only deleted the row). The REST route just forwards the result.
  • Before the row is deleted, the storage key is registered in _emdash_media_upload_attempts with status cleanup (MediaRepository.trackStorageKeyForCleanup, an upsert on the primary key). The existing cleanup sweep (runSystemCleanupfindUploadAttemptsForCleanupremoveUploadAttempt) already retries rows in that state once the media row is gone, so a failed storage delete is retried on every cron tick instead of being lost. deleteCompletedUploadAttempts skips cleanup rows so a cron tick landing between the upsert and the row delete cannot reap the marker.
  • The delete order stays DB row first, then storage — the ordering fix: make media uploads reliable across storage backends #2273 introduced so a delete racing an in-flight upload cannot strand the freshly uploaded object. That existing race test still passes.
  • DELETE /_emdash/api/media/:id responds { deleted: true, storageDeleted: boolean } (OpenAPI MediaDeleteResponse). Additive; the admin client only checks the envelope.

Not changed: the public file route still serves any key that exists in storage (second half of the issue). Gating it on a media row adds a query to a logged-out route; with deletes now retried until the object is gone, I'd leave that for a maintainer decision.

Tests (all fail before the fix): route reports storageDeleted: false when storage rejects, the object stays reachable and the next runSystemCleanup removes it; MCP media_delete removes the stored object; a cleanup-marked key survives the sweep while its media row still exists.

Refs #3049 (leaves the file-route question open, see above)

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...
  • I have included screenshots below if this PR changes the UI

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude Fable 5.1

Screenshots / test output

Not applicable (no UI change). i18n and Discussion items are n/a: no admin strings, bug fix.

…torage failures

The media delete handler now removes the object from storage itself, so
the MCP media_delete tool deletes the file as its description promises.
Before the row is removed the storage key is registered in the upload
attempts table, so a failed storage delete is retried by the cleanup
sweep instead of leaving an unreferenced, still-public object. The API
response reports storageDeleted so callers learn when the object survived.
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6934481

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Sep 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@3067

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@3067

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@3067

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@3067

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@3067

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@3067

emdash

npm i https://pkg.pr.new/emdash@3067

create-emdash

npm i https://pkg.pr.new/create-emdash@3067

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@3067

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@3067

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@3067

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@3067

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@3067

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@3067

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@3067

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@3067

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@3067

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@3067

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@3067

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@3067

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@3067

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@3067

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@3067

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@3067

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@3067

commit: 6934481

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The approach is sound: centralising storage deletion in handleMediaDelete and reusing the existing cleanup sweep lets a failed storage delete retry later while preserving the row-first ordering that protects the upload race. The REST and MCP paths now correctly remove the stored object and surface storageDeleted, and the tests reproduce the failure and cleanup scenarios.

The remaining gap is the plugin-facing delete path. packages/core/src/plugins/context.ts still deletes only the media row and never touches the stored object or the cleanup marker, so plugins calling ctx.media.delete() will leave orphan files behind — exactly the bug this PR is fixing for REST and MCP. That path should share the same cleanup pattern (or delegate to the handler) before merge.

Otherwise the diff is clean: no new logged-out queries, no SQL injection surface, the OpenAPI schema is additive, and the changeset is acceptable as user-facing release notes.


Findings

  • [needs fixing] packages/core/src/plugins/context.ts:674-684

    The plugin-facing media.delete() still deletes only the database row:

    		async delete(id: string): Promise<boolean> {
    			const deleted = await mediaRepo.delete(id);
    			// ...
    			if (deleted) {
    				invalidateSiteSettingsCache();
    			}
    			return deleted;
    		},

    It never deletes the stored object and never registers the cleanup marker, so a plugin delete leaves an orphan file behind — the same failure mode #3049 fixes for the admin API and MCP. The tests for the site-settings cache even call this out as one of the three media delete paths (packages/core/tests/unit/settings/settings.test.ts).

    It should share the same cleanup pattern as the handler, e.g. by delegating to handleMediaDelete (and adding the import from ../api/handlers/media.js):

    		async delete(id: string): Promise<boolean> {
    			const result = await handleMediaDelete(db, id, storage ?? null);
    			if (result.success && result.data?.deleted) {
    				invalidateSiteSettingsCache();
    				return true;
    			}
    			return false;
    		},
    

    This reuses the row-first delete order, storage failure handling, and retry marker rather than recreating the bug for plugin callers.

@swissky

swissky commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review: ctx.media.delete() now delegates to handleMediaDelete (same row-first order, storage delete, cleanup marker), with a test that fails without the change. Changeset names the plugin API too. (6934481)

@github-actions github-actions Bot added size/L review/needs-rereview Author pushed changes since the last review and removed size/M review/needs-review No maintainer or bot review yet labels Sep 11, 2026
@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Sep 11, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review of the media-delete fix.

The plugin-facing gap from the last review is resolved: packages/core/src/plugins/context.ts now delegates ctx.media.delete() to handleMediaDelete, so plugin callers share the same row-first delete order, storage-failure handling, and cleanup-marker retry as the REST and MCP paths. The new tests reproduce the failed-storage-delete and cleanup-marker scenarios, and the OpenAPI response change is additive.

The approach is sound—centralising storage deletion in handleMediaDelete and reusing the existing cleanup sweep means a failed storage delete is retried on every cron tick instead of being lost, while preserving the row-first ordering that protects the upload race.

One real delete path still has the original bug. packages/core/src/media/local-runtime.ts implements the local media provider's delete() and is called by DELETE /_emdash/api/media/providers/:providerId/:itemId. It still deletes the storage object directly, swallows any storage error, deletes the row without registering a cleanup marker, and does so in storage-before-row order. That leaves the same orphan-file failure mode this PR is fixing, and the site-settings cache tests explicitly list local-runtime.delete() as one of the three media-delete paths. It should share the same cleanup pattern, e.g. by delegating to handleMediaDelete.

Otherwise the diff is clean: no new logged-out queries, no SQL-injection surface, the changeset is useful release-facing prose, and comments are acceptable.


Findings

  • [needs fixing] packages/core/src/media/local-runtime.ts:130

    The local media provider's delete() still handles deletion manually: it calls storage.delete() directly, swallows any error, then deletes the row, with no cleanup marker and no retry path. This is the same failure mode #3049 fixes for the admin API, MCP tool, and plugin API—if the storage delete fails, the row is gone and the object is never found again. The site-settings cache tests also call this out as one of the three media-delete paths.

    It should share the centralized handler so a failed local delete is retried by the cleanup sweep:

    		async delete(id: string) {
    			const result = await handleMediaDelete(resolveDb(), id, storage);
    			if (result.success) {
    				invalidateSiteSettingsCache();
    			}
    		},
    

    This reuses the row-first ordering, storage-failure reporting, and cleanup marker that the other delete paths now use. Be sure to add the import for handleMediaDelete from ../api/handlers/media.js.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Sep 11, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core review/awaiting-author Reviewed; waiting on the author to respond size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant