Skip to content

fix(core): don't create pending media rows when storage can't pre-sign - #2284

Closed
Hridayesh13 wants to merge 2 commits into
emdash-cms:mainfrom
Hridayesh13:fix/media-signed-upload-pending-rows
Closed

fix(core): don't create pending media rows when storage can't pre-sign#2284
Hridayesh13 wants to merge 2 commits into
emdash-cms:mainfrom
Hridayesh13:fix/media-signed-upload-pending-rows

Conversation

@Hridayesh13

Copy link
Copy Markdown

What does this PR do?

POST /_emdash/api/media/upload-url creates the pending media record before asking storage for a signed URL:

const mediaItem = await repo.createPending({ ... });   // :107
const signedUrl = await emdash.storage.getSignedUploadUrl({ ... });   // :117

Adapters that cannot pre-sign throw NOT_SUPPORTED on that second call. For R2 accessed through a Worker binding this is unconditional and permanent — a property of the adapter, not a misconfiguration (packages/cloudflare/src/storage/r2.ts) — and local storage behaves the same way. The route catches it and correctly answers 501, the admin correctly falls back to direct upload, and the user-visible flow works. But the insert has already committed, and nothing rolls it back.

Those rows are invisible: findMany() defaults to status='ready' and the list query exposes no status parameter, so they never appear in the library or through the API. cleanupPendingUploads() exists but I could not find anything scheduling it. In practice the media table grows by one dead row per upload attempt, forever, on every such deployment. On our install 11 accumulated during one short debugging session before we noticed — and only because we were instrumenting something else.

This PR moves the getSignedUploadUrl() call above createPending(), so the record is written only once it is known a URL can be issued. The 501 response and the client fallback are unchanged.

This is the "possible side effect" noted at the end of #2256 — that issue reports the same endpoint from the user-facing angle (the failed request in DevTools). This PR fixes only the row-accumulation half; the question that issue raises, whether the admin should skip the probe altogether for adapters known not to support pre-signing, is a separate design call and is left open.

Refs #2256

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) — n/a, no admin UI change
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a, bug fix

Screenshots / test output

packages/core/tests/integration/api/media-upload-url-pending.test.ts covers the endpoint against a storage adapter that cannot pre-sign: it still answers 501, it writes no row, and repeated attempts do not accumulate. It detects a revert — with the fix backed out and everything else unchanged:

✓ answers 501 NOT_SUPPORTED so the client falls back to direct upload
× does not create a pending media row
× does not accumulate rows across repeated attempts

Test Files  1 failed (1)
     Tests  2 failed | 1 passed (3)

With the fix applied:

✓ tests/integration/api/media-upload-url-pending.test.ts (3 tests) 127ms
  Test Files  1 passed (1)
       Tests  3 passed (3)

Note the first assertion passes either way — that is deliberate, it pins the 501 contract this PR must not change.

Full suites on this branch's base (668184f):

packages/core   390 passed | 1 skipped (391 files), 5024 passed | 3 skipped
packages/admin  105 passed (105 files), 1241 passed
pnpm format     clean (no files changed outside this PR)

Two notes on the shared gates, both reproduced on a clean checkout of main @ 668184f with no changes applied, i.e. pre-existing and unrelated to this PR:

  • pnpm typecheck fails in packages/coresrc/plugins/context.ts(1212,24): error TS2345: Argument of type '"cache:purge"' is not assignable to parameter of type 'PluginCapability'.
  • pnpm lint reports 1 warning (and --deny-warnings turns it into a failure) — typescript(no-unnecessary-type-assertion) at packages/cloudflare/src/sandbox/bridge.ts:1210.

Happy to rebase once those are sorted if it makes CI easier to read.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5 (Claude Code)

The upload-url route created the pending media record before asking
storage for a signed URL. Adapters that cannot pre-sign -- local storage,
and R2 accessed through a Worker binding -- throw NOT_SUPPORTED at that
point, which the catch turns into a 501 so the client falls back to
direct upload. The record was already committed by then.

Those rows are invisible (findMany defaults to status='ready' and the
list query exposes no status filter) and are only removed by
cleanupPendingUploads(), which nothing schedules, so the table grew by
one dead row per upload attempt on every such deployment.

Ask storage for the URL first; the record is created only once it is
known that one can be issued.
@changeset-bot

changeset-bot Bot commented Jul 30, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3425382

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/plugin-mcp-smoke 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
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto 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

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/M labels Jul 30, 2026
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@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.

This is the right fix for the right problem: moving the storage getSignedUploadUrl() call before repo.createPending() prevents the route from leaving orphaned pending rows on adapters that cannot pre-sign, while preserving the existing 501 NOT_SUPPORTED fallback so the admin client still switches to direct upload. The change is minimal, well-scoped, and matches the architecture.

I read the full diff, the route, MediaRepository, the RBAC permission map, and the changeset. The authorization check (media:upload), error envelope shape, SQL-safety, and changeset all look correct. The new integration test exercises observable behavior (no row is written, repeated calls don't accumulate, 501 still returned) rather than asserting implementation details.

The only issues are two AGENTS.md comment-discipline violations: both the route and the new test file paste PR-style narrative into code comments, including adapter names and a reference to the broader issue's design question. These should be trimmed to one-sentence explanations.

Comment on lines +106 to +115
// Get signed upload URL from storage.
//
// This must happen BEFORE the pending record is created. Adapters that
// cannot pre-sign -- local storage, and R2 accessed through a Worker
// binding -- throw NOT_SUPPORTED here, which the catch below turns into a
// 501 so the client falls back to direct upload. Creating the record first
// meant every such request committed a `status='pending'` row with no
// object behind it: invisible in the library (findMany defaults to
// `status='ready'`, and the list query exposes no status filter) and only
// ever removed by cleanupPendingUploads(), which nothing schedules. On

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.

[needs fixing] This comment block is PR narrative pasted into the source: it restates the bug, names internal adapters and cleanupPendingUploads(), and walks through the rejected implementation. AGENTS.md says comments must not be PR descriptions, justification, narrative, or issue/PR references. The ordering constraint is genuinely non-obvious, so a one-line "why" is fine — trim the rest.

Suggested change
// Get signed upload URL from storage.
//
// This must happen BEFORE the pending record is created. Adapters that
// cannot pre-sign -- local storage, and R2 accessed through a Worker
// binding -- throw NOT_SUPPORTED here, which the catch below turns into a
// 501 so the client falls back to direct upload. Creating the record first
// meant every such request committed a `status='pending'` row with no
// object behind it: invisible in the library (findMany defaults to
// `status='ready'`, and the list query exposes no status filter) and only
// ever removed by cleanupPendingUploads(), which nothing schedules. On
// Get the signed upload URL before creating the pending row, so adapters
// that cannot pre-sign don't leave an orphaned `pending` record.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 3425382 — trimmed to your suggested two lines. Kept the ordering note since that is the one thing a future reader could undo by accident.

Comment on lines +1 to +17
/**
* The signed-upload endpoint must not leave a `pending` media row behind when
* storage cannot pre-sign.
*
* `POST /_emdash/api/media/upload-url` is the first call the admin's
* `uploadMedia()` makes; it falls back to direct multipart upload when the
* endpoint answers 501. Two shipped adapters can never pre-sign and always
* throw NOT_SUPPORTED — local storage (`LocalStorage.getSignedUploadUrl`) and
* R2 accessed through a Worker binding (`R2Storage.getSignedUploadUrl`) — so
* on those setups the 501 fallback is the *normal* path, taken on every single
* upload.
*
* The route used to create the pending record before asking storage for the
* URL, so each of those attempts committed a `status='pending'` row with no
* object behind it. The rows are invisible (`findMany` defaults to
* `status='ready'` and the list query exposes no status filter) and are only
* removed by `cleanupPendingUploads()`, which nothing schedules — so the table

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.

[needs fixing] The file-level JSDoc is a copy of the PR description: it narrates the bug, compares adapters, and refers to the original issue's open design question. AGENTS.md says comments must not be PR descriptions, narrative, or issue references — that context belongs in the PR/commit message, not evergreen test comments. Keep a single sentence, or delete the block since the describe title already states the purpose.

Suggested change
/**
* The signed-upload endpoint must not leave a `pending` media row behind when
* storage cannot pre-sign.
*
* `POST /_emdash/api/media/upload-url` is the first call the admin's
* `uploadMedia()` makes; it falls back to direct multipart upload when the
* endpoint answers 501. Two shipped adapters can never pre-sign and always
* throw NOT_SUPPORTED local storage (`LocalStorage.getSignedUploadUrl`) and
* R2 accessed through a Worker binding (`R2Storage.getSignedUploadUrl`) so
* on those setups the 501 fallback is the *normal* path, taken on every single
* upload.
*
* The route used to create the pending record before asking storage for the
* URL, so each of those attempts committed a `status='pending'` row with no
* object behind it. The rows are invisible (`findMany` defaults to
* `status='ready'` and the list query exposes no status filter) and are only
* removed by `cleanupPendingUploads()`, which nothing schedules so the table
/** The signed-upload endpoint must not leave a pending media row behind when storage cannot pre-sign. */

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 3425382 — replaced with your suggested one-liner.

@Hridayesh13

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

AGENTS.md scopes comments to a future reader of the code: not PR
descriptions, not narrative about the previous behaviour, not references
to the reporting issue. Keep the one thing a future reader would get
wrong -- that the call order is load-bearing -- and drop the rest, which
lives in the commit message and changeset.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review needs-rebase and removed review/needs-review No maintainer or bot review yet labels Jul 30, 2026
@khoinguyenpham04 khoinguyenpham04 added the bot:review Trigger an emdashbot code review on this PR label Jul 31, 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.

This is still the right fix for the right problem. Moving getSignedUploadUrl() ahead of repo.createPending() stops the route from leaving hidden pending rows on adapters that cannot pre-sign, while keeping the existing 501 NOT_SUPPORTED fallback intact so the admin client falls back to direct upload. I re-read the route, MediaRepository, the storage and RBAC types, the changeset, and the related upload-widening tests.

Verification summary:

  • Authorization check (media:upload) is unchanged and correct.
  • Error envelope shape and SQL safety are unchanged.
  • The changeset is user-facing and present-tense.
  • The new integration test exercises observable behavior (no row written, repeated calls don't accumulate, 501 still returned) rather than asserting implementation details.
  • The route comment from the prior review has been trimmed to a concise one-sentence explanation of the ordering constraint.

One AGENTS.md comment-discipline issue remains: the test helper comment still pastes PR-specific adapter names into the code. I'd like that trimmed before merge.

import type { Storage } from "../../../src/storage/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";

/** Storage that behaves like local storage / an R2 binding: it cannot pre-sign. */

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.

[needs fixing] This comment still embeds PR-specific adapter names (local storage / an R2 binding). That context belongs in the PR description, not in a comment that future readers will see long after the adapter list changes. Trim it to a generic, one-sentence description of what the helper does.

Suggested change
/** Storage that behaves like local storage / an R2 binding: it cannot pre-sign. */
/** Storage adapter that does not support pre-signed upload URLs. */

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 31, 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 Jul 31, 2026
@Hridayesh13

Copy link
Copy Markdown
Author

Closing this — #2273 landed while it was in review and supersedes it.

main now calls getSignedUploadUrl() before repo.createPending(), which is the ordering this PR was arguing for. But it resolves the underlying problem differently: instead of surfacing 501 NOT_SUPPORTED and writing no row, it catches the error, sets signedUrl = null, and deliberately creates the pending row so it can hand back an authenticated same-origin /_emdash/api/media/{id}/upload endpoint. The row stops being an orphan because it becomes the upload target.

That makes this a rewrite rather than a rebase — the integration test here asserts "no row written, repeated calls don't accumulate, 501 still returned", and all three of those are now wrong by design.

Thanks for the review passes — the comment-discipline notes were fair and I applied them in 3425382 (the last one, on the test helper, is moot now).

Happy to open a fresh, narrow PR covering the new contract — that the NOT_SUPPORTED path returns the streaming upload URL and that the pending row it creates is the one the direct upload confirms — if that coverage would be useful. Say the word and I'll put it up.

@Hridayesh13 Hridayesh13 closed this Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants