Attachments Guide
Attachments connect uploaded files to your models: a Post has a cover
Attachments Guide
Attachments connect uploaded files to your models: a Post has a cover
image and many images, each stored on a storage disk,
tracked in one attachments table, with image validation and thumbnail
variants built in. Declarations live on the model, so collection names,
one/many kinds, and variant names are all checked at compile time.
import { Attachable, defineModel, hasOneAttached, hasManyAttached } from '@guren/core'
import { posts } from '@/db/schema'
export class Post extends Attachable(defineModel(posts), {
cover: hasOneAttached({
image: 'require',
variants: { thumb: { width: 320 }, og: { width: 1200 } },
}),
images: hasManyAttached({ image: 'require' }),
draftPdf: hasOneAttached(), // opaque bytes; width/height/placeholder stay null
}) {}
// In a controller — one call:
async store() {
const data = await this.validateBody(CreatePostSchema)
const post = await Post.create(data)
const cover = await this.file('cover')
if (cover) {
await Post.attach(post.id, 'cover', cover)
}
return this.redirect(`/posts/${post.id}`)
}
Setup
1. Add the attachments table
Your app owns the table (the same convention as the sessions table): add the
snippet for your dialect to db/schema.ts and run a migration.
PostgreSQL (timestamps must use withTimezone: true — guren check
enforces this):
import { index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const attachments = pgTable('attachments', {
id: text('id').primaryKey(), // ULID
attachableType: text('attachable_type').notNull(), // model class name
attachableId: text('attachable_id').notNull(), // text covers int and uuid PKs
collection: text('collection').notNull().default('default'),
disk: text('disk').notNull(),
path: text('path').notNull(),
name: text('name').notNull(),
contentType: text('content_type').notNull(),
size: integer('size').notNull(),
width: integer('width'),
height: integer('height'),
variants: jsonb('variants').$type<Record<string, AttachmentVariantRecord>>(),
placeholder: text('placeholder'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => [index('attachments_attachable_idx').on(t.attachableType, t.attachableId, t.collection)])
MySQL:
import { index, int, json, mysqlTable, text, timestamp, varchar } from 'drizzle-orm/mysql-core'
export const attachments = mysqlTable('attachments', {
id: varchar('id', { length: 26 }).primaryKey(),
attachableType: varchar('attachable_type', { length: 255 }).notNull(),
attachableId: varchar('attachable_id', { length: 255 }).notNull(),
collection: varchar('collection', { length: 255 }).notNull().default('default'),
disk: varchar('disk', { length: 255 }).notNull(),
path: varchar('path', { length: 1024 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
contentType: varchar('content_type', { length: 255 }).notNull(),
size: int('size').notNull(),
width: int('width'),
height: int('height'),
variants: json('variants').$type<Record<string, AttachmentVariantRecord>>(),
placeholder: text('placeholder'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, (t) => [index('attachments_attachable_idx').on(t.attachableType, t.attachableId, t.collection)])
SQLite:
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
attachableType: text('attachable_type').notNull(),
attachableId: text('attachable_id').notNull(),
collection: text('collection').notNull().default('default'),
disk: text('disk').notNull(),
path: text('path').notNull(),
name: text('name').notNull(),
contentType: text('content_type').notNull(),
size: integer('size').notNull(),
width: integer('width'),
height: integer('height'),
variants: text('variants', { mode: 'json' }).$type<Record<string, AttachmentVariantRecord>>(),
placeholder: text('placeholder'),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
}, (t) => [index('attachments_attachable_idx').on(t.attachableType, t.attachableId, t.collection)])
The variants column must be JSON-capable (jsonb on Postgres, json on
MySQL, text(..., { mode: 'json' }) on SQLite). Import the
AttachmentVariantRecord type from @guren/core.
2. Configure the layer
// config/attachments.ts
import { configureAttachments } from '@guren/core'
import { attachments } from '@/db/schema'
import { storage } from './storage'
export const { Attachment } = configureAttachments({
table: attachments,
storage: () => storage, // your StorageManager, resolved lazily
disk: 'media', // default disk for new attachments
})
Import this module once at boot (for example from src/app.ts, next to your
other config). The returned Attachment is a ready-made model bound to the
table with morphTo('attachable', 'attachable') pre-declared — useful for
morph relations and advanced queries. The framework itself deliberately
exports no Attachment class; the app-local name comes from this call.
Additional options:
| Option | Default | Purpose |
|---|---|---|
disks |
{} |
Per-disk visibility, e.g. { media: 'public', docs: 'private' }. Private disks serve URLs via temporaryUrl(). |
maxPixels |
52_000_000 |
Decode cap in pixels (decompression-bomb defense). |
maxImageBytes |
50_000_000 |
Encoded-input cap in bytes, checked before any decode. |
processor |
Bun-native | Custom ImageProcessor, or null to disable image decoding. |
queue |
— | The app's QueueManager, resolved lazily; enables attach(..., { queued: true }). |
urlExpiresIn |
5 minutes | Lifetime of temporaryUrl() links for private disks. |
Working with attachments
All statics are typed against the declaration — a typo in a collection name or a variant name is a compile error, not a runtime surprise.
// Attach bytes (File, Blob, or Uint8Array — never a path string)
await Post.attach(post.id, 'cover', file)
await Post.attach(post.id, 'images', file, { name: 'photo.jpg', disk: 'archive' })
// hasOne replaces (the old row and its objects are purged);
// hasMany appends.
// Detach: whole collection, or one attachment on a hasMany
await Post.detach(post.id, 'cover')
await Post.detach(post.id, 'images', attachmentId)
// Load attachments for a page of records (one indexed query)
const withCovers = await Post.withAttachments(posts, ['cover', 'images'])
// → each record gains `cover: AttachmentData | null` and `images: AttachmentData[]`
// URLs
const url = await Post.attachmentUrl(post, 'cover')
const thumb = await Post.attachmentUrl(post, 'cover', { variant: 'thumb' })
// Remove everything a record owns (call this from destroy actions)
await Post.purgeAttachments(post.id)
AttachmentData is the resource-facing shape — { id, collection, name, contentType, size, width, height, url, placeholder, variants } — ready to
return from a JsonResource.toArray() so pages receive typed attachment
props. placeholder is a ThumbHash LQIP data URL you can render while the
real image loads.
Raw rows via relations
The table follows the ORM's morph convention, so the ordinary relation machinery works too when you want the rows themselves:
export class Post extends Attachable(defineModel(posts), { /* … */ }) {}
Post.morphMany('attachments', Attachment, 'attachable')
const loaded = await Post.with('attachments').get() // all collections, raw rows
morphMany loads all collections of a record; the typed per-collection
path is withAttachments().
Image validation and security
When a collection declares image: 'require' (or 'allow'), uploads pass
a three-gate pipeline:
- Byte cap — input larger than
maxImageBytesis rejected with 413. - Header dimensions — a dependency-free header parser (PNG, JPEG, GIF,
WebP, AVIF/HEIC) reads the declared dimensions and rejects anything over
maxPixelswith 422 before a decoder allocates pixel buffers. - Full decode — the image is actually decoded. Truncated or corrupt files that lie in their headers fail here with 422. Sniffed and client-declared content types are recorded but never trusted for the image/not-image decision.
Gates 1 and 2 are pure JavaScript and run on every runtime. Gate 3 runs wherever an image processor exists (see below); without one, uploads are accepted on header evidence and dimensions come from the header.
The image option per collection:
- unset — opaque bytes: no image pipeline,
width/height/placeholderstaynull(documents, archives, …) 'allow'— images are decoded and measured; other files are stored as opaque bytes'require'— non-images are rejected with a 422ValidationException(the error keys on the collection name, so Inertia forms display it)'forbid'— anything that sniffs as an image is rejected with 422
Other rules that hold everywhere:
- Bytes only.
attach()acceptsFile | Blob | Uint8Arrayand nothing else — filesystem path strings are an arbitrary-file-read primitive and are rejected at both the type level and runtime. - HEIC/HEIF is rejected with 415 by default. HEIC decoding depends on
OS codecs — it typically works on a macOS dev machine and fails on Linux
production, and the default must not let that skew pass silently. Opt in
with
accepts: { heic: 'convert' }: the upload is decoded and stored as JPEG, and still answers 415 on runtimes whose codecs cannot decode it. The rejection applies whenever the image pipeline runs —image: 'allow'collections included, so an iPhone HEIC photo is 415 there too unless the collection opts into'convert'; only collections with noimagepolicy at all store HEIC bytes as opaque files. - Filenames are sanitized (no path separators or control characters) before becoming part of an object key.
- Serving inherits your app's rules. Attachments add no serving route:
public disks serve via
disk.url(), private ones viadisk.temporaryUrl(). If a disk serves user uploads over your own domain, make sure it sends correctContent-TypeandX-Content-Type-Options: nosniffheaders — an inline SVG served as a page is a script.
Variants
Declare named variants on the collection; they are generated at attach time:
cover: hasOneAttached({
image: 'require',
variants: {
thumb: { width: 320 },
og: { width: 1200, height: 630, fit: 'inside', format: 'webp', quality: 80 },
},
})
fit supports 'fill' and 'inside' (what the Bun-native processor
actually implements; a crop mode can be added without breaking changes).
Every declared variant gets a status entry on the attachment row —
ready, failed, unavailable (no processor on this runtime), or
pending (queued generation, below). attachmentUrl(post, 'cover', { variant: 'thumb' }) serves a ready variant's own URL and falls back
to the original's URL for anything else, so pages keep rendering; a
variant name that was never declared throws instead of silently serving the
original.
Runtimes and processors
The default processor is Bun-native (Bun.Image) and is resolved by
feature detection — image variants and full-decode validation require a Bun
runtime with Bun.Image (Bun 1.4; the API first appeared in 1.3.14). On
older Bun versions and non-Bun runtimes (Node/Lambda, Workers):
- attachments still store and serve normally;
- declared variants are recorded as
unavailableand their URLs fall back to the original; - you can inject any
ImageProcessorimplementation (for example a sharp-backed one) viaconfigureAttachments({ processor }).
Whether a specific format (HEIC, AVIF) can be decoded or encoded is a property of the OS codecs, discovered at call time — expect 415 responses for formats the deployed runtime cannot handle, and test uploads on the runtime you deploy to.
Queued generation
attach(..., { queued: true }) moves the image work off the request path:
the request runs only the synchronous gates (byte cap, header dimensions,
HEIC signature), stores the original, seeds every declared variant as
pending, and dispatches GenerateVariantsJob. A worker then runs the
deferred full decode, converts HEIC originals where the collection opted
in, generates the variants, and flips the status records to ready (or
failed). Until it does, variant URLs fall back to the original and the
placeholder stays null.
// config/attachments.ts
export const { Attachment } = configureAttachments({
table: attachments,
storage: () => storage,
disk: 'media',
queue: () => queueManager, // the app's QueueManager, resolved lazily
})
// anywhere
await Post.attach(post.id, 'cover', file, { queued: true })
What to know:
configureAttachments()registers the job, so any worker process that boots the app's config (bunx guren queue:work) can process it. Point the worker at a runtime with an image processor — Bun withBun.Image, or a customconfigureAttachments({ processor }); a worker without one settles the variants asunavailable.- Without the
queueoption,queued: truedispatches through the app's already-booted queue driver, and throws a clear error before writing anything when there is none. - The full decode moves to the worker, so the one class the synchronous
gates cannot catch — bytes whose header lies — is detected after
acceptance: on an
image: 'require'collection the job purges the attachment; on other collections the bytes stay as an opaque file. - On Cloudflare Workers this is the only mode that generates variants; see the Cloudflare guide.
URLs and visibility
Visibility is declared per disk in the attachments config, not per attachment — matching drivers like R2 where visibility is a property of the bucket:
configureAttachments({
// …
disks: { media: 'public', docs: 'private' },
})
- Public disks:
attachmentUrl()returnsdisk.url(path)— CDN-cacheable, zero app CPU. - Private disks:
attachmentUrl()returnsdisk.temporaryUrl(path, expiry).
Two limitations to know about, both inherited from the drivers:
LocalDriver.temporaryUrl()returns a plain public URL, so "private on the local disk" is not actually private.R2Drivercan only presign whenpresigncredentials are configured — without themtemporaryUrl()throws, so private attachments on R2 require thepresignoption.
Lifecycle and deletion
The polymorphic attachableType/attachableId pair cannot carry a foreign
key, so no database cascade is possible. Deletion is explicit:
async destroy() {
const { id } = this.validateParams(PostIdParamSchema)
await Post.purgeAttachments(id) // objects first, rows after
await Post.delete({ id })
return this.redirect('/posts')
}
detach/purgeAttachmentsdelete storage objects first (one prefix per attachment), then the rows. A crash between the two leaves a row pointing at nothing — which the next render surfaces loudly — rather than invisible orphaned objects.- Model delete hooks are not used as the purge mechanism: they only fire
on one of the delete paths and receive the where clause, not the row.
Call
purgeAttachments()explicitly in destroy actions. - With
SoftDeletes, soft-deleting a record leaves its attachments in place (restore must work); callpurgeAttachments()onforceDeletepaths.
Sweeping orphans: attachments:prune
The contract is explicit-plus-sweep: whatever slips past the explicit purge
— records deleted through paths that never called purgeAttachments(),
storage prefixes left behind by crashed or raced jobs — is reclaimed by the
AttachmentsPruneCommand sweeper. Register it in the console kernel:
// src/console.ts
import { AttachmentsPruneCommand } from '@guren/core'
kernel.register(AttachmentsPruneCommand)
bunx guren attachments:prune # remove rows whose record no longer exists
bunx guren attachments:prune --objects # also remove attachments/ prefixes no row references
bunx guren attachments:prune --dry-run # report without deleting
Orphan rows are detected by resolving each attachableType through
Model.morphMap and querying for the owning records — so register every
model that declares attachments:
Model.morphMap = { Post, User }
The sweep deletes only on positive evidence: a type missing from the morph map, a failing existence query, or an unlistable disk is reported and left alone — an outage must never turn into a mass deletion. Run it from a scheduled job or CI on whatever cadence fits the app.
What the agent commands verify
bunx guren checkvalidates thatconfigureAttachments()binds a table yourdb/schema.tsactually exports — the layer takes the table untyped, so a renamed schema export would otherwise only fail at runtime, on the first attach.bunx guren audittreats uploads handed to a typedattach()as validated (the declaration-driven pipeline is the validation); an action that reads other body input still needsvalidateBody().
Testing
Use the memory storage driver and configure against your test database:
import { configureAttachments, StorageManager } from '@guren/core'
import { attachments } from '@/db/schema'
const storage = new StorageManager({
default: 'media',
disks: { media: { driver: 'memory', url: 'https://cdn.test' } },
})
configureAttachments({ table: attachments, storage: () => storage, disk: 'media' })
const record = await Post.attach(post.id, 'cover', new File([bytes], 'cover.png'))
expect(await storage.disk('media').exists(record.path)).toBe(true)