Хранимые файлы

Фото, аватары, вложения: файлы, которые приложение загружает, а бэкенд хранит. Файл — это поле строки в базе данных — t.file(). Эндпоинт принимает файл параметром и пишет его в поле; чтение строки даёт его url. Убирать за собой нечего: файл удаляется вместе со строкой.

Обзор

TypeScript
// gallery.server.ts — выполняется на бэкенде приложения
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)          // файл уходит вместе со строкой
}
TypeScript
// main.ts — приложение
import { addPhoto, photos } from "./gallery.server"

const list = UIColumn().style({ gap: 12 })

const reload = async () => {
  list.setContent((await photos()).map((photo) =>
    UIColumn(
      // размер картинки известен до её загрузки: строка держит своё место
      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()

Поле

TypeScript
t.file()                // один файл; обязателен в insert
t.file().optional()     // один файл или null
t.file().array()        // упорядоченный список файлов; insert может его опустить
t.image({ max: 512 })   // картинка, хранимая в пригодном для показа виде — см. Картинки

Файловое поле читается как значение:

TypeScript
type StoredFile = {
  url: string        // откуда отдаются байты
  name: string       // "holiday.jpg"
  type: string       // "image/jpeg", "video/mp4", "application/pdf"
  size: number       // байты
  width?: number     // размер изображения в пикселях (PNG, JPEG, GIF, WebP)
  height?: number
}
  • Запрос без select возвращает файловые поля вместе со строкой, как скаляры. С select назови поле: select({ caption: true, image: true }).
  • url публичный, и его нельзя угадать. Любой, у кого он есть, может открыть файл — не храни то, что может видеть только владелец.
  • type, width и height читаются из байтов файла, а не из его имени.
  • Строки не фильтруются и не сортируются по файловому полю.

Картинки

t.image() — файловое поле для картинок: фото, аватар. Оно принимает те же модификаторы, пишется и читается так же; отличается то, что хранится:

TypeScript
const User = model({ avatar: t.image({ max: 512 }).optional() })
const Post = model({ photos: t.image().array(), files: t.file().array() })
  • Картинка хранится в пригодном для показа виде: повёрнутая так, как задумала камера, без метаданных (где и когда снято фото), как WebP — holiday.jpg читается обратно как holiday.webp.
  • max — максимум длинной стороны, в пикселях (2048, если не указано). Большая картинка уменьшается; меньшая никогда не увеличивается. Фото 12 МП на 4 МБ хранится в нескольких сотнях КБ.
  • То, что не картинка, отклоняется с RpcError 400 — эндпоинт ничего не проверяет.
  • Читается как StoredImage: StoredFile, у которого width и height есть всегда.
  • GIF хранится как пришёл.

Используй t.file() для того, что должно храниться байт в байт (документ, оригинал). Для вложений обоих видов держи два поля и раскладывай загрузки по их 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)) })
}

Загрузка

Параметр типа File — это загрузка. Приложение передаёт File, который у него есть — из openFilePicker, из takePhoto() камеры, с canvas — и вызов несёт байты с собой:

TypeScript
// сервер
export async function setAvatar(avatar: File) {
  const user = await db.auth.requireUser().select({ id: true })
  await db.user.update(user.id, { avatar })
}

// приложение
await setAvatar(await camera.takePhoto())

File может стоять где угодно среди параметров: один, необязательный, в списке, внутри объекта.

TypeScript
export async function createPost(text: string, photos: File[], cover?: File) {
  return db.post.insert({ text, photos, cover })
}
  • На сервере у File есть name, size и type (прочитанный из его байтов). Он сохраняется записью в поле t.file() или t.image(); тот, который эндпоинт никуда не записал, выбрасывается, когда вызов завершается.
  • Один файл — не больше 10 МБ, один вызов несёт не больше 10 файлов. Больший отклоняется с RpcError 413 до запуска эндпоинта.
  • Проверяй, что принимаешь, в эндпоинте — файл выбирает вызывающий:
TypeScript
export async function setAvatar(avatar: File) {
  if (avatar.size > 2 * 1024 * 1024) throw new ApiError(413, "An avatar is at most 2 MB")
  // …
}

Замена и удаление

TypeScript
await db.user.update(id, { avatar: newFile })   // старый аватар удаляется
await db.user.update(id, { avatar: null })      // только .optional(): поле пустое, файл удалён
await db.user.delete(id)                        // файлы строки удаляются

Удаление строки удаляет её файлы, как бы строка ни ушла — и когда её забирает onDelete("cascade"), потому что удалена строка, которой она принадлежит.

Список пишется целиком. Верни файлы, которые остаются, в нужном тебе порядке; чего нет в списке, то удаляется:

TypeScript
// сервер
export async function setPhotos(postId: number, photos: (File | StoredFile)[]) {
  await db.post.update(postId, { photos })
}

// приложение: убрать первое фото, добавить новое в конец
const [post] = await posts()
await setPhotos(post.id, [...post.photos.slice(1), picked])
  • Хранимый файл принадлежит одной строке. Переданный в поле, где он уже есть, он остаётся; переданный в другую строку, он туда не переносится — загрузи файл заново.
  • insert принимает только загрузки.
  • updateMany не пишет файловые поля: файл идёт в одну строку.

Тест загрузки

Flow-тест (lecodes test, tests/*.flow.json) отвечает на диалог выбора файла через pick на том шаге, чьё действие его открывает. Без pick диалог отменяется — openFilePicker резолвится ничем.

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" } }
] }
  • Путь ищется рядом с файлом flow.
  • { "name", "width", "height" } — PNG такого размера, созданный для прогона, { "name", "text" } — текстовый файл: тесту загрузки не нужен файл, хранимый в проекте.
  • Пикер одного файла берёт первый из списка.
  • Работает headless, с --desktop, --lite и --web; бэкенд теста стартует с пустой базой и пустой папкой файлов.

Где лежат файлы

байты url начинается с
lecodes dev, desktop run .lecodes/files в проекте адрес, по которому приложение достучалось до бэкенда
lecodes test .lecodes/test-files, очищается вместе с тестовой базой —
le.codes хранилище платформы, по проекту; превью редактора и опубликованное приложение держат свои раздельно https://le.codes/p/<project>

Запуск локальной базы заново (изменение схемы, которое нельзя мигрировать) отодвигает вместе с ней и её файлы — в .lecodes/files-old.

Подводные камни

TypeScript
const Doc = model({ meta: t.struct({ scan: t.file() }) })      // ✗ файл — поле самой модели
const Doc = model({ scan: t.file() })                           // ✓

await db.photo.insert({ caption, image: image.name })            // ✗ ошибка типа: поле принимает File
await db.photo.insert({ caption, image })                        // ✓

await db.repost.insert({ image: original.image })                // ✗ хранимый файл принадлежит своей строке
// ✓ передай File из приложения заново или укажи на строку: t.one("Photo")

const url = photo.image.url
await db.note.insert({ text: url })                              // ✗ url умирает вместе со строкой фото

Смотрите также