Skip to content

Duplicate-upload guard: same file, same item returns the existing row (#7) - #75

Merged
vgururao merged 1 commit into
blygger:mainfrom
aneeshsathe:feat/media-dedupe
Oct 20, 2026
Merged

vgururao merged 1 commit into
blygger:mainfrom
aneeshsathe:feat/media-dedupe

Conversation

@aneeshsathe

Copy link
Copy Markdown
Contributor

The duplicate-upload guard from #7, as its own PR, in the shape you described there. The other half of #7, attachments per version, is left for a separate PR.

What it does. When POST /api/media carries an item_id, the server takes a SHA-256 of the file's bytes before anything goes to R2. If that item already has a row with the same hash, the same MIME type and the same inline placement, the server stores nothing. It returns the existing row as 200 { id, url, mime, duplicate: true }. Otherwise it behaves as before and returns 201.

  • The MIME type is part of the match. The MCP parity test showed why: it sends the same bytes under five types, and returning the first row would have handed back the wrong type and extension.
  • The existing row's alt is kept. A second upload with different alt text does not overwrite it.
  • Uploads without an item_id (the avatar, a composer with no draft yet) are never deduplicated.
  • Two identical uploads at exactly the same moment can still both store. The guard is a lookup, not a unique index, because rows uploaded before the migration have no hash.

Migration 0028_media_sha256.sql (the number 0029's header reserves for this):

  • adds a nullable media.sha256, with no backfill;
  • adds an index on (item_id, sha256);
  • recreates change_media_update in 0027's remote-safe shape: SELECT RAISE … WHERE and no CASE … END;, with sha256 added to the watched columns so the polling-cache oracle still covers every column.

Contract. uploadMedia gains a 200 response with duplicate: true, alongside the existing 201. openapi.json and sdk/generated/ are regenerated. The Studio's composer inserts media.url whatever the status, so a re-dropped image lands in the text with no UI change. docs/api.md notes the behaviour.

Scope. Returning an existing row writes nothing, so a draft-only token that re-sends a published item's file gets that row back and changes nothing. A new test asserts this: no R2 object, no row, and the item document unchanged.

Tests.

  • test/media.test.ts, two new cases:
    • Same file, same item: 200, same id, and one media[] entry with the first alt.
    • Different bytes, a different type, another item, the other placement, or no item: each one 201 and a new row.
  • test/mcp-media.oracle.test.ts:
    • The draft-scope case above.
    • The parity test now gives REST and MCP an item each, so MCP's own write path is still exercised rather than answered as a duplicate.
  • test/media-visibility-security.oracle.test.ts: the "later upload stays private" case now uploads different bytes. The same file again would correctly return the already-published row.

Results. Typecheck clean, and sdk:generate is stable. Worker suite 1748 passed, 5 skipped, 0 failed (142 files). UI 97/97. There is no e2e change, since the Studio needs none.

Refs #7 (the guard half; per-version attachments stay open there).

🤖 Generated with Claude Code

…blygger#7)

POST /api/media with an item_id hashes the bytes (SHA-256) before the R2
write. A row on that item with the same hash, MIME type and placement is
returned as 200 { id, url, mime, duplicate: true } and nothing is stored.
Migration 0028 adds a nullable media.sha256 (no backfill) with an
(item_id, sha256) index, and recreates change_media_update in the
remote-safe shape with the new column watched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vgururao
vgururao merged commit 5eadba3 into blygger:main Oct 20, 2026
17 of 18 checks passed
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.

2 participants