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

TypeScript
// 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
}
TypeScript
// 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

TypeScript
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 Pictures

A file field is read as a value:

TypeScript
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 select returns file fields with the row, like scalars. With select, name the field: select({ caption: true, image: true }).
  • The url is public and cannot be guessed. Anyone who has it can open the file — do not store what only its owner may see.
  • type, width and height are 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:

TypeScript
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.jpg is read back as holiday.webp.
  • max is 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 RpcError 400 — the endpoint checks nothing.
  • It is read as a StoredImage: a StoredFile whose width and height are 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:

TypeScript
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:

TypeScript
// 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.

TypeScript
export async function createPost(text: string, photos: File[], cover?: File) {
  return db.post.insert({ text, photos, cover })
}
  • On the server a File has a name, a size and a type (read from its bytes). It is stored by writing it to a t.file() or t.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 RpcError 413 before the endpoint runs.
  • Check what you accept in the endpoint — the caller chooses the file:
TypeScript
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

TypeScript
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 deleted

Deleting 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:

TypeScript
// 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.
  • insert takes uploads only.
  • updateMany does 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.

JSON
{ "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, --lite and --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

TypeScript
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 row

See also