Stored files
Photos, avatars, attachments: files the app uploads and the backend keeps. A file is a field of a
row in the database — t.file(). An endpoint takes the file as
a parameter and writes it to the field; reading the row gives its url. There is nothing to clean up:
a file is deleted with its row.
At a glance
// gallery.server.ts — runs on the app backend
const Photo = model({
caption: t.string(),
image: t.file(),
createdAt: t.date().default("now"),
})
const db = defineDb({ Photo })
export async function addPhoto(caption: string, image: File) {
return db.photo.insert({ caption, image })
}
export async function photos() {
return db.photo.order("createdAt", "desc")
}
export async function removePhoto(id: number) {
await db.photo.delete(id) // the file goes with the row
}// main.ts — the app
import { addPhoto, photos } from "./gallery.server"
const list = UIColumn().style({ gap: 12 })
const reload = async () => {
list.setContent((await photos()).map((photo) =>
UIColumn(
// an image's size is known before it loads: the row keeps its place
UIImage(photo.image.url).style({ width: "100%", aspectRatio: photo.image.width! / photo.image.height! }),
UIText(photo.caption).style({ color: "white" }),
).style({ gap: 6 }),
))
}
const add = async () => {
const file = await openFilePicker({ accept: "image/*" })
if (!file) return
try {
await addPhoto("New photo", file)
await reload()
} catch (e) {
if (!(e instanceof RpcError)) throw e
toast(e.message) // "a file is at most 10 MB"
}
}
UIScreen(
UIButton(UIText("Add a photo").style({ color: "white" }))
.style({ height: 44, borderRadius: 10, bgColor: "#4f7cff" })
.named("add")
.onClick(add),
UIScrollable(list).style({ flex: "1" }),
).style({ bgColor: "#1e2230", p: 20, pt: "safe-top", gap: 16 }).open()
reload()The field
t.file() // one file; required in insert
t.file().optional() // one file, or null
t.file().array() // an ordered list of files; an insert may leave it out
t.image({ max: 512 }) // a picture, kept fit to show — see PicturesA file field is read as a value:
type StoredFile = {
url: string // where the bytes are served from
name: string // "holiday.jpg"
type: string // "image/jpeg", "video/mp4", "application/pdf"
size: number // bytes
width?: number // an image's size in pixels (PNG, JPEG, GIF, WebP)
height?: number
}- A query without
selectreturns file fields with the row, like scalars. Withselect, name the field:select({ caption: true, image: true }). - The
urlis public and cannot be guessed. Anyone who has it can open the file — do not store what only its owner may see. type,widthandheightare read from the file's bytes, not from its name.- A row is not filtered or sorted by a file field.
Pictures
t.image() is a file field for pictures — a photo, an avatar. It takes the same modifiers and is
written and read the same way; what differs is what is kept:
const User = model({ avatar: t.image({ max: 512 }).optional() })
const Post = model({ photos: t.image().array(), files: t.file().array() })- The picture is stored fit to show: turned the way the camera meant, without its metadata (where
and when a photo was taken), as WebP —
holiday.jpgis read back asholiday.webp. maxis the most its longer side may be, in pixels (2048 when not said). A larger picture is scaled down; a smaller one is never enlarged. A 12 MP photo of 4 MB is stored in a few hundred KB.- What is not a picture is refused with
RpcError400 — the endpoint checks nothing. - It is read as a
StoredImage: aStoredFilewhosewidthandheightare always there. - A GIF is kept as it came.
Use t.file() for what must be kept byte for byte (a document, an original). For attachments of
both kinds, keep two fields and sort the uploads by their type:
export async function createPost(text: string, attached: File[]) {
const isImage = (f: File) => f.type.startsWith("image/")
return db.post.insert({ text, photos: attached.filter(isImage), files: attached.filter((f) => !isImage(f)) })
}Uploading
A parameter of type File is an upload. The app passes a File it has — from
openFilePicker, a camera's takePhoto(), a canvas —
and the call carries the bytes with it:
// server
export async function setAvatar(avatar: File) {
const user = await db.auth.requireUser().select({ id: true })
await db.user.update(user.id, { avatar })
}
// app
await setAvatar(await camera.takePhoto())A File may stand anywhere among the parameters: alone, optional, in a list, inside an object.
export async function createPost(text: string, photos: File[], cover?: File) {
return db.post.insert({ text, photos, cover })
}- On the server a
Filehas aname, asizeand atype(read from its bytes). It is stored by writing it to at.file()ort.image()field; one that the endpoint writes nowhere is dropped when the call ends. - One file is at most 10 MB, one call carries at most 10 files. A bigger one is refused with
RpcError413 before the endpoint runs. - Check what you accept in the endpoint — the caller chooses the file:
export async function setAvatar(avatar: File) {
if (avatar.size > 2 * 1024 * 1024) throw new ApiError(413, "An avatar is at most 2 MB")
// …
}Replacing and removing
await db.user.update(id, { avatar: newFile }) // the old avatar is deleted
await db.user.update(id, { avatar: null }) // .optional() only: the field is empty, the file deleted
await db.user.delete(id) // the row's files are deletedDeleting a row deletes its files however the row goes — also when onDelete("cascade") takes it
because the row it belongs to was deleted.
A list is written whole. Hand back the files that stay, in the order you want; what is not in the list is deleted:
// server
export async function setPhotos(postId: number, photos: (File | StoredFile)[]) {
await db.post.update(postId, { photos })
}
// app: drop the first photo, add a new one at the end
const [post] = await posts()
await setPhotos(post.id, [...post.photos.slice(1), picked])- A stored file belongs to one row. Handed to the field it is already in, it stays; handed to another row, it is not moved there — upload the file again.
inserttakes uploads only.updateManydoes not write file fields: a file goes to one row.
Testing an upload
A flow test (lecodes test, tests/*.flow.json) answers the file dialog with pick, on the step whose action opens
it. Without pick the dialog is cancelled — openFilePicker resolves with nothing.
{ "steps": [
{ "tap": "add", "pick": "holiday.jpg", "expect": { "text": "New photo" } },
{ "tap": "attach", "pick": ["a.png", "b.pdf"] },
{ "tap": "avatar", "pick": { "name": "me.png", "width": 400, "height": 300 } },
{ "tap": "attach", "pick": { "name": "notes.txt", "text": "hello" } }
] }- A path is looked for beside the flow file.
{ "name", "width", "height" }is a PNG of that size made for the run,{ "name", "text" }a text file: a test of an upload needs no file kept in the project.- A picker of one file takes the first of a list.
- It works headless, with
--desktop,--liteand--web; the backend of a test starts with an empty database and an empty files folder.
Where the files are
| the bytes | the url starts with | |
|---|---|---|
lecodes dev, desktop run |
.lecodes/files in the project |
the address the app reached the backend at |
lecodes test |
.lecodes/test-files, emptied with the test database |
— |
| le.codes | the platform's storage, per project; the editor's preview and the published app keep theirs apart | https://le.codes/p/<project> |
Starting the local database over (a schema change that cannot be migrated) moves its files aside
with it, to .lecodes/files-old.
Pitfalls
const Doc = model({ meta: t.struct({ scan: t.file() }) }) // ✗ a file is a field of the model itself
const Doc = model({ scan: t.file() }) // ✓
await db.photo.insert({ caption, image: image.name }) // ✗ type error: the field takes the File
await db.photo.insert({ caption, image }) // ✓
await db.repost.insert({ image: original.image }) // ✗ a stored file belongs to its row
// ✓ pass the File again from the app, or point at the row: t.one("Photo")
const url = photo.image.url
await db.note.insert({ text: url }) // ✗ the url dies with the photo's rowSee also
- Database — models, queries,
onDelete - Server functions — endpoints and what their parameters may be
- Files & share —
openFilePicker, files on the device