Хранимые файлы
Фото, аватары, вложения: файлы, которые приложение загружает, а бэкенд хранит. Файл — это поле
строки в базе данных — t.file(). Эндпоинт принимает
файл параметром и пишет его в поле; чтение строки даёт его url. Убирать за собой нечего: файл
удаляется вместе со строкой.
Обзор
// 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) // файл уходит вместе со строкой
}// 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()Поле
t.file() // один файл; обязателен в insert
t.file().optional() // один файл или null
t.file().array() // упорядоченный список файлов; insert может его опустить
t.image({ max: 512 }) // картинка, хранимая в пригодном для показа виде — см. КартинкиФайловое поле читается как значение:
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() — файловое поле для картинок: фото, аватар. Оно принимает те же модификаторы, пишется
и читается так же; отличается то, что хранится:
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 МБ хранится в нескольких сотнях КБ.- То, что не картинка, отклоняется с
RpcError400 — эндпоинт ничего не проверяет. - Читается как
StoredImage:StoredFile, у которогоwidthиheightесть всегда. - GIF хранится как пришёл.
Используй t.file() для того, что должно храниться байт в байт (документ, оригинал). Для вложений
обоих видов держи два поля и раскладывай загрузки по их 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)) })
}Загрузка
Параметр типа File — это загрузка. Приложение передаёт File, который у него есть — из
openFilePicker, из takePhoto() камеры, с canvas —
и вызов несёт байты с собой:
// сервер
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 может стоять где угодно среди параметров: один, необязательный, в списке, внутри объекта.
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 файлов. Больший отклоняется с
RpcError413 до запуска эндпоинта. - Проверяй, что принимаешь, в эндпоинте — файл выбирает вызывающий:
export async function setAvatar(avatar: File) {
if (avatar.size > 2 * 1024 * 1024) throw new ApiError(413, "An avatar is at most 2 MB")
// …
}Замена и удаление
await db.user.update(id, { avatar: newFile }) // старый аватар удаляется
await db.user.update(id, { avatar: null }) // только .optional(): поле пустое, файл удалён
await db.user.delete(id) // файлы строки удаляютсяУдаление строки удаляет её файлы, как бы строка ни ушла — и когда её забирает onDelete("cascade"),
потому что удалена строка, которой она принадлежит.
Список пишется целиком. Верни файлы, которые остаются, в нужном тебе порядке; чего нет в списке, то удаляется:
// сервер
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 резолвится ничем.
{ "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.
Подводные камни
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 умирает вместе со строкой фотоСмотрите также
- База данных — модели, запросы,
onDelete - Серверные функции — эндпоинты и что могут быть их параметры
- Файлы и шеринг —
openFilePicker, файлы на устройстве