Skip to content

feat(uploads): derived files (variants) for thumbnails, previews and poster frames (experimental) - #3020

Open
Tobbe wants to merge 3 commits into
mainfrom
tobbe-upload-variants
Open

Tobbe wants to merge 3 commits into
mainfrom
tobbe-upload-variants

Conversation

@Tobbe

@Tobbe Tobbe commented Oct 11, 2026

Copy link
Copy Markdown
Member

Summary

Adds derived files (variants) to @cedarjs/uploads, marked experimental. An upload can have variants (thumbnails, previews, a video's poster frame), each an Upload row of its own, linked to its source and named (thumb-164, preview). Cedar keeps the bookkeeping; the app supplies only the function that makes one.

Cedar owns: the link to the source, deletion with the source, making a missing variant once even under concurrency (in one process and across processes), remembering a failure with a retry-after, remaking every variant of one name, and reading/serving. The package does not depend on sharp, pdf.js or ffmpeg: a generator is any function from source bytes to new bytes.

Implementation plan: docs/implementation-plans/2026-10-12-upload-derived-files.md

The problem

In a production Cedar app (Effibo) that shows thumbnails of photos and PDFs and a drawn first page of each PDF, most of the effort went into plumbing, not image making:

  • three models got the same set of fields for their thumbnails (two upload relations and a failure timestamp each, plus preview fields on two of them), and Upload got a back-relation for each: eight of its eleven back-relations are derived files
  • the cleanup job's list of relations that own an upload has to be updated by hand for every new relation, or files get swept or leak
  • a race-fix helper exists only because two processes can make the same derived file at once, plus an in-process map of in-flight promises
  • "couldn't be made, try again after a day" is a column and a check per kind of file
  • the redraw script, after a fix to PDF drawing, had to clear fields, delete uploads and remake them one model at a time

How it fits what uploads already has

Uploads has no processors, hooks or Prisma extension: services call storeFile() / deleteFile() explicitly, and every file is one row in the Upload table with bytes on a named StorageProvider. Variants build on exactly that:

  • A variant is an Upload row, on any configured target (S3, FS, or inline for DB targets, with the same 1 MB cap), with its source's userId and organizationId, so ownership checks and tenancy apply unchanged.
  • Serving is unchanged: a variant's id works with @withSignedUrl / @withDataUri; getVariantUrl() is the same logic for services.
  • Deletion stays explicit, per the uploads plan's two-gate test: deleteFile() takes an optional targets and then deletes the upload's variants (bytes, then rows) before the upload. Nothing happens inside a Prisma query.
  • status does the bookkeeping: pending is the claim while a variant is being made, completed is made, failed (with no file) is the failure memo, timed by updatedAt. cleanupStaleUploads() already ignores such failed rows and turns a long-abandoned pending claim into a remembered failure. tokenId holds the attempt's claim id while pending.
  • The base UploadDatabase type is unchanged; a separate structural UploadVariantDatabase type covers the new columns, so apps that don't opt in are unaffected.

API

model Upload {
  // ...existing fields...
  sourceId String?
  variant  String?
  source   Upload?  @relation("UploadVariants", fields: [sourceId], references: [id], onDelete: Restrict)
  variants Upload[] @relation("UploadVariants")

  @@unique([sourceId, variant])
}
import { defineUploadVariants, deleteFile } from '@cedarjs/uploads'

export const uploadVariants = defineUploadVariants({
  db,
  targets,
  target: targets.thumbnails,
  variants: {
    'thumb-164': async ({ readSource }) => ({
      data: await sharp(await readSource()).resize(164, 164).webp().toBuffer(),
      mimeType: 'image/webp',
    }),
    preview: { generate: drawFirstPage, target: targets.previews },
    // One variant made from another: drawn once, shared
    'pdf-thumb': async ({ readVariant }) => {
      const page = await readVariant('preview')
      return page ? { data: await cutSquare(page), mimeType: 'image/webp' } : null
    },
  },
  retryFailedAfter: 24 * 60 * 60 * 1000, // default
})

await uploadVariants.getVariant(uploadId, 'thumb-164')    // row, made if missing; null if it failed
await uploadVariants.readVariant(uploadId, 'thumb-164')   // { upload, data } | null
await uploadVariants.getVariantUrl(uploadId, 'thumb-164') // signed URL, or data: URI for DB targets
await uploadVariants.findVariant(uploadId, 'thumb-164')   // only if made; never makes
await uploadVariants.findVariants(uploadIds, 'thumb-164') // Map<sourceId, row>, one query
await uploadVariants.regenerateVariants('preview')        // { deleted, made, failed, errors }

await deleteFile(target, { db, upload, targets })         // the upload and its variants

Why this schema: variants in the Upload table reuse everything uploads already does; the app's models need no thumbnail columns; the unique index is the cross-process lock; and onDelete: Restrict (not Cascade, which would orphan bytes silently) makes deleting a source without its variants a loud HAS_VARIANTS error that names both ways out.

What the tests guard

packages/uploads/src/__tests__/variants.test.ts, against the package's real SQLite test database, with trivial generators:

  • a variant that doesn't exist yet is made on read, linked to its source, with the source's owner; later reads don't call the generator
  • two concurrent requests in one process run the generator once
  • two concurrent requests from two separate instances sharing only the database (two "processes") run the generator once and get the same row; a stale claim from a dead process is taken over
  • a generator failure (thrown or null) is remembered and not retried until retryFailedAfter has passed, then retried and made
  • regenerateVariants() remakes one variant name and leaves the others untouched (same row, same file); it also retries remembered failures; remake: false only deletes
  • deleting the source with targets deletes its variants and their stored files on every target, and leaves other sources' variants; without targets it's refused with HAS_VARIANTS; a variant finished after its source was deleted leaves no file behind
  • readVariant() chaining, self-dependency refused, invalid/unknown names, variants of variants, DB-target data URIs, signed URLs, findVariants()

The test helper also assigns the generated Prisma client to UploadDatabase & UploadVariantDatabase, so the structural types can't drift from Prisma's.

Ran: vitest run for the package (141 tests), tsc --noEmit, ESLint on packages/uploads/src, Prettier, yarn build, yarn check:package.

Open questions

  • Schema: should yarn cedar setup uploads add the variant columns for everyone, or stay opt-in (as now, documented) while experimental?
  • Migrating an app with per-model thumbnail relations: set sourceId/variant on existing rows in a data migration. Worth a recipe once a second app does it.
  • Sync vs. job-based generation: getVariant() makes on demand; apps that can't afford that call it from a job and use findVariants() in requests. Is a "queue it" mode tied to @cedarjs/jobs worth adding?
  • Transient failures are remembered like permanent ones. A named error a generator can throw to release the claim instead is cheap to add when needed.
  • Large sources: readSource() buffers; a video generator may want a stream.
  • Serving private files: variant URLs are signed for whoever calls; authorization is the caller's, as for any upload.
  • Sources outside uploads (files that live in a third-party system) aren't covered.

Related proposals from the same work: declarative recurring jobs and unique jobs in @cedarjs/jobs, and a "locked child" runner for parsing untrusted files in a sandboxed Node process (generators that parse untrusted files would use it). Sniffing a file's type by its bytes belongs in uploads and is noted as a follow-up in the plan, not built here.

🤖 Generated with Claude Code

@netlify

netlify Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for cedarjs ready!

Name Link
🔨 Latest commit 2b2fae4
🔍 Latest deploy log https://app.netlify.com/projects/cedarjs/deploys/6acc0b7fa2618e0008f0067f
😎 Deploy Preview https://deploy-preview-3020--cedarjs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 48 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 3f2d8760-ff20-4c84-9dd0-b6622e9b0b85

📥 Commits

Reviewing files that changed from the base of the PR and between c79560e and 2b2fae4.


📒 Files selected for processing (12)
  • .changesets/3020.md
  • docs/docs/uploads.md
  • docs/implementation-plans/2026-10-12-upload-derived-files.md
  • packages/uploads/README.md
  • packages/uploads/src/__tests__/helpers/testDb.ts
  • packages/uploads/src/__tests__/unit-test-schema.prisma
  • packages/uploads/src/__tests__/variants.test.ts
  • packages/uploads/src/deleteFile.ts
  • packages/uploads/src/errors.ts
  • packages/uploads/src/index.ts
  • packages/uploads/src/types.ts
  • packages/uploads/src/variants.ts

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added this to the next-release milestone Oct 11, 2026
@greptile-apps

greptile-apps Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 0/5

[Critical impact] Adds database schema columns and variant lifecycle to uploads.

Fix the file leaks and broken retry, dependency, and regeneration paths before merging.

Findings

  1. P1 Deletion misses a finished file ▶
  2. P1 Failed saves leave files behind ▶
  3. P1 Retries keep the wrong target ▶
  4. P1 Cleanup cancels fresh retries ▶
  5. P1 Circular variants hang requests ▶
  6. P1 Regeneration skips a replacement ▶
  7. P2 Deletion reports the wrong cause ▶

Summary

Adds experimental upload variants with generators, stored results, failure retries, reading helpers, regeneration, and deletion with the source.

  • Apps can make and read named files derived from uploads.
  • Apps can remake every stored variant of one name.
  • Deleting an upload also clears its derived files.

Reviews (1) · Last reviewed commit: "chore(uploads): wrap the changeset" · Reviewed by Greptile

Comment on lines +270 to +274
if (row.storageKey) {
await resolveTarget(targets, row.target).delete(row.storageKey)
}

await db.upload.deleteMany({ where: { id: row.id } })

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.

P1 Deletion misses a finished file

If deleteVariants() reads a pending row with no storageKey, the generator can complete before deleteMany() runs. deleteVariantRow() then skips the new file but deletes its row. The generator saved successfully, so its lost-claim cleanup never runs. Claim the row before deleting it, or condition deletion on the saved state and retry if it changed.

Comment on lines +501 to +505
const { count } = await db.upload.updateMany({
where: ours,
data: {
status: 'completed',
storageKey,

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.

P1 Failed saves leave files behind

If the completion updateMany() rejects after target.write() succeeds, the new file is left behind. The save is outside the write's try block, and the pending row still has no storageKey, so cleanupStaleUploads() cannot find the file. Handle completion errors and safely remove or retain a record of the written file, as storeFile() does when saving its row fails.

Comment on lines +503 to +505
data: {
status: 'completed',
storageKey,

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.

P1 Retries keep the wrong target

If a deployment changes a variant's target, retrying a failed row or taking over an old claim writes to the current definition.target. Completion never updates the row's target, so the completed row names the old provider while its file lives on the new one. Reads and deletion then use the wrong provider. Save the target used by the winning attempt together with its result.

Comment on lines +603 to +605
const { count } = await db.upload.updateMany({
where: { id: row.id, status: 'failed', updatedAt: { lt: cutoff } },
data: { status: 'pending', tokenId: claim },

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.

P1 Cleanup cancels fresh retries

A failed variant retry reuses its old createdAt. cleanupStaleUploads() checks that time, not the fresh claim's updatedAt. With the defaults, a retry after one day immediately qualifies for the one-hour cleanup cutoff. If cleanup runs during the attempt, it marks the row failed; the successful generator discards its result, and another retry is delayed a day. Make cleanup honor the age of the current variant claim.

Comment on lines +656 to +660
const key = `${sourceId}\0${name}`
const running = inFlight.get(key)

if (running) {
return running

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.

P1 Circular variants hang requests

Start getVariant(source, 'a') and getVariant(source, 'b') together, with each generator calling readVariant() for the other. Their separate chains contain only their own names, so both cycle checks pass and each returns the other's unresolved promise. Both requests hang indefinitely; claimTimeout does not apply to shared promises. Track active dependencies before returning an existing attempt so this cycle is refused too.

Comment on lines +810 to +814
if (remake) {
const made = await getVariant(sourceId, name).catch((e: unknown) => {
onError(e, { sourceId, variant: name })
return null
})

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.

P1 Regeneration skips a replacement

If regenerateVariants() deletes a row whose generator is still running in this instance, getVariant() reuses that same attempt. The old attempt finds its row gone, removes its output, and returns null. Regeneration counts a failure and never starts a replacement, even with remake: true and a working generator. Wait for or safely retire the deleted row's attempt before requesting a fresh one.

Comment on lines +78 to +83
if (!options.targets) {
throw new UploadError(
'HAS_VARIANTS',
`Upload '${upload.id}' has derived files (variants), so its row ` +
'cannot be deleted on its own. Pass `targets` to deleteFile() to ' +
'delete the variants with it, or call deleteVariants() first.',

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.

P2 Deletion reports the wrong cause

P2003 does not identify a variant relation. If an app model still references an upload with restricted deletion, it raises the same error—even without variant columns. This branch labels it HAS_VARIANTS and recommends helpers that cannot fix the reference. Only use this message when variants are established as the cause; preserve other foreign-key errors.

@nx-cloud

nx-cloud Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 2b2fae4

Command Status Duration Result
nx run-many -t build:pack --exclude create-ceda... ✅ Succeeded 1s View ↗
nx run-many -t build ✅ Succeeded <1s View ↗
nx run-many -t build --output-style=stream ✅ Succeeded 2m 47s View ↗

💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗


☁️ Nx Cloud last updated this comment at 2026-10-11 22:31:43 UTC

This branch has not been deployed

No deployments
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.

1 participant