Uploads
Uploads
S3-backed file uploads using presigned POST (browser uploads directly to S3). File metadata is stored in the shared Postgres files table, scoped by user_id. Plan limits enforced via checkEntitlement.
Architecture
- Browser uploads directly to S3 using a presigned POST URL (no file bytes through your server) , the S3 SDK calls themselves were already portable and needed no change during the Postgres port; only the DB-touching metadata functions did
- Metadata (name, type, size, s3_key) stored in the shared
filespgTable (src/lib/db/schema.ts) - Download via presigned GET URL generated on demand
- Gate ordering: S3 credential check and subscription lookup happen before
withUserTransaction
Key files
| File | Purpose |
|---|---|
src/features/uploads/server/uploads.mutations.ts | getUploadUrlFn, addUploadToDbFn, deleteUploadFn |
src/features/uploads/server/uploads.queries.ts | listUploadsFn, getUploadUrlForDownloadFn |
src/features/uploads/server/uploads.server.ts | DB operations |
src/features/uploads/uploads.constants.ts | MAX_FILE_SIZE_BYTES, schemas |
Activation
Set these env vars , no code change needed:
AWS_S3_IAM_ACCESS_KEY=...
AWS_S3_IAM_SECRET_KEY=...
AWS_S3_FILES_BUCKET=my-bucket
AWS_S3_REGION=us-east-1
VITE_S3_FILES_BUCKET=my-bucket # same value , exposes to client for feature detection
config.uploads.enabled is derived from VITE_S3_FILES_BUCKET. The files page shows a setup card when false.
Upload flow
- Client calls
getUploadUrlFnwith file name, type, size - Server checks S3 config, fetches subscription, enforces plan limit, returns presigned POST
{ url, fields, s3Key } - Client POSTs directly to S3 using the URL and fields
- On S3 success, client calls
addUploadToDbFnto record metadata
import { getUploadUrlFn, addUploadToDbFn } from '@/features/uploads';
// Step 1: get presigned POST
const urlResult = await getUploadUrlFn({ data: { name, type, size } });
if (!urlResult.ok) return;
const { url, fields, s3Key } = urlResult.data;
// Step 2: upload directly to S3
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append('file', file);
await fetch(url, { method: 'POST', body: form });
// Step 3: record metadata
await addUploadToDbFn({ data: { name, type, size, s3Key } });
Gate ordering
See docs/warpkit/patterns/gate-ordering.md for the general rule (read-only gates before the
transaction, mutable gates inside with a fresh read). This feature's specific application: S3
check and subscription lookup are read-only , they happen before withUserTransaction to avoid
holding the transaction open during slow operations:
requireUser()
check S3 bucket env var (sync, before transaction)
createPresignedPost(...) (async S3 call, before transaction)
fetch subscription from DB (async read-only, before transaction)
withUserTransaction()
checkUserRateLimit(...)
checkEntitlement(plan, 'maxFileUploads', count)
return ok({ url, fields, s3Key })
Quota check stays advisory/racy by design, same as before the port: no row exists yet at presign time, so this can't be a hard gate , withUserTransaction's weaker isolation (read-committed, not the old per-user mutex) makes this pre-existing gap slightly wider, not new. Accepted, same as notes's documented rate-limit/plan-limit TOCTOU gap , see architecture/postgres-port.md for the general isolation-level caveat.
Plan limits
Configured in config.ts:
limits: {
free: { maxFileUploads: 10 },
solo: { maxFileUploads: 100 },
pro: { maxFileUploads: -1 }, // -1 = unlimited
team: { maxFileUploads: -1 }
}
Deletion
deleteUploadFn removes the metadata row inside withUserTransaction, then fires the S3 DeleteObjectCommand after the transaction commits (fire-and-forget, .catch(() => {})). S3 deletion failure does not affect the response.
File size limit
MAX_FILE_SIZE_BYTES in uploads.constants.ts. Enforced via the presigned POST content-length-range condition , S3 rejects oversized uploads before they land.