База данных
У бэкенда проекта есть база данных. Серверный код (*.server.ts, см.
Серверные функции) описывает её таблицы как модели и читает и
пишет их через типизированный db. Нет ни SQL, ни строки подключения, ни файла миграции: модели
в коде — это и есть схема.
База данных есть только на сервере. Приложение добирается до неё через эндпоинты; для данных,
которые остаются на устройстве, используй localStorage.
Обзор
// tasks.server.ts — выполняется на бэкенде приложения
const Task = model({
title: t.string(),
done: t.bool().default(false),
createdAt: t.date().default("now"),
})
const db = defineDb({ Task })
export type TaskView = { id: number, title: string, done: boolean }
export async function listTasks(): Promise<TaskView[]> {
return db.task.order("id", "desc").select({ id: true, title: true, done: true })
}
export async function addTask(title: string): Promise<TaskView> {
const text = title.trim()
if (!text) throw new ApiError(400, "A task needs a title")
const { id } = await db.task.insert({ title: text })
return { id, title: text, done: false }
}
export async function setDone(id: number, done: boolean) {
await db.task.update(id, { done })
}
export async function clearDone() {
return db.task.where({ done: true }).deleteMany() // сколько было удалено
}// main.ts — приложение
import { addTask, clearDone, listTasks, setDone, type TaskView } from "./tasks.server"
const list = UIColumn().style({ gap: 8 })
let input: UIInput
const Row = (task: TaskView) =>
UIButton(UIText(`${task.done ? "✓" : "○"} ${task.title}`).style({ color: "white", fontSize: 18 }))
.style({ justifyContent: "flex-start", px: 14, height: 44, borderRadius: 10, bgColor: "#2a3042" })
.named(`task-${task.id}`)
.onClick(async () => {
await setDone(task.id, !task.done)
await reload()
})
const reload = async () => {
list.setContent((await listTasks()).map(Row))
}
const add = async () => {
try {
await addTask(input.value)
input.value = ""
await reload()
} catch (e) {
if (!(e instanceof RpcError)) throw e
toast(e.message) // "A task needs a title"
}
}
UIScreen(
UIRow(
input = UIInput()
.style({ flex: "1", height: 44, px: 12, borderRadius: 10, bgColor: "white", color: "black", placeholder: "New task" })
.named("title")
.onSubmit(add),
UIButton(UIText("Add").style({ color: "white" }))
.style({ px: 18, height: 44, borderRadius: 10, bgColor: "#4f7cff" })
.named("add")
.onClick(add),
).style({ gap: 8 }),
list,
UIButton(UIText("Clear done").style({ color: "#9aa3b8" }))
.named("clear")
.onClick(async () => {
await clearDone()
await reload()
}),
).style({ bgColor: "#1e2230", p: 20, pt: "safe-top", gap: 16 }).open()
reload()model, t, defineDb и ref — глобалы серверных файлов. db — твой: держи его обычным
const — он только серверный, так что приложение
всё равно не смогло бы им пользоваться.
Модели
const Author = model({
name: t.string(),
email: t.string().unique(),
bio: t.string().optional(),
role: t.enum("reader", "editor", "admin").default("reader"),
joinedAt: t.date().default("now"),
posts: t.many("Post", "author"),
})
const Post = model({
title: t.string().index(),
published: t.bool().default(false),
views: t.int().default(0),
tags: t.string().array().default([]),
cover: t.struct({ url: t.string(), width: t.int(), height: t.int() }).optional(),
author: t.one("Author").onDelete("cascade"),
editor: t.one("Author").optional(),
comments: t.many("Comment"),
})
const Comment = model({
text: t.string(),
post: t.one("Post").onDelete("cascade"),
author: t.one("Author"),
})
const db = defineDb({ Author, Post, Comment }) // db.author, db.post, db.comment- Имя модели — её ключ в
defineDb, в PascalCase. Её таблица — то же имя с маленькой первой буквы:Author→db.author,CartItem→db.cartItem. - У каждой модели есть
id: number, который база выдаёт при вставке, считая с 1. Не объявляй его. Для id, который нельзя угадать, объявиid: t.uuid(): он тоже генерируется при вставке, и это строка. - У проекта одна база данных: вызови
defineDbодин раз, со всеми моделями, и импортируй этотdbв остальные серверные файлы. ВторойdefineDbроняет старт сервера (a project has one database, and defineDb is called 2 times …). - Запрашивай внутри эндпоинтов. Запрос во время загрузки файла падает: база открывается после неё.
Поля
| поле | в TypeScript | |
|---|---|---|
t.string() |
string |
|
t.int() |
number |
целое число, 64-битное |
t.float() |
number |
|
t.bool() |
boolean |
|
t.date() |
number |
миллисекунды с 1970, как даёт Date.now() |
t.uuid() |
string |
|
t.enum("a", "b") |
"a" | "b" |
|
t.json<T>() |
T |
любое JSON-значение; T — лишь тип, которым ты его читаешь |
t.struct({ … }) |
объект из этих полей | вложенный объект строки, без собственного id |
t.file() |
{ url, name, type, size, … } |
файл, который загрузило приложение — Хранимые файлы |
t.image({ max? }) |
{ url, name, type, size, width, height } |
картинка: хранится как WebP, не больше max px по длинной стороне (2048) — Хранимые файлы |
field.optional() // может быть null; опущенное в insert, оно равно null
field.default(value) // опущенное в insert, оно равно `value`; у даты: .default("now")
field.unique() // нет двух строк с одним значением
field.index() // делает фильтрацию и сортировку по полю быстрой на большой таблице
field.array() // список значений: t.string().array() — это string[]
field.fulltext() // строковое поле с поиском по словам: where({ text: { $search: "fox" } })- Поле без
.optional()и без.default()обязательно вinsert. Это верно и для массива: объявиt.string().array().default([]), чтобы его можно было опустить.
Связи
t.one("Author") // эта строка указывает на одного Author
t.one("Author").optional() // …или ни на кого
t.many("Post") // строки Post, которые указывают на эту — только для чтения
t.many("Post", "author") // то же, с именем поля Post, когда у него два t.one("Author")
t.list("Post") // упорядоченный список Post, хранимый на этой строке; одна строка может повторяться- Связь называет свою модель строкой. Неизвестное имя — ошибка типа на этом поле.
t.manyничего не хранит. Это другой конецt.one, он нужен для чтения: посты автора. Чтобы добавить пост автору, вставь пост с этимauthor.t.list— список, который ты упорядочиваешь вручную: плейлист, рейтинг. Он пишется с этой стороны.
t.one("Post").onDelete("cascade") // пост удалён → эта строка удаляется вместе с ним
t.one("Author").onDelete("setNull") // автор удалён → это поле становится null (по умолчанию для optional)
t.one("Author").onDelete("restrict") // автора нельзя удалить, пока на него указывает эта строка (по умолчанию для обязательного)С моделями выше удаление автора удаляет его посты и комментарии к этим постам, очищает
editor у постов, которые он редактировал, и отклоняется, пока остаётся его комментарий — сначала
удали их.
Чтение
db.author — запрос всех авторов. Каждый метод отвечает новым запросом; await его выполняет.
const everyone = await db.author // строки
const ann = await db.author.where({ email: "ann@example.com" }).first() // строка или null
const published = await db.post.where({ published: true }).count() // число
const latest = await db.post.order("id", "desc").limit(20)query.where(filter) // повторные вызовы объединяются через AND
query.order(field, "asc" | "desc") // по ОДНОМУ полю (второе — ошибка типа); по умолчанию "asc"
query.limit(n)
query.skip(n)
query.after(id) // строки после этой в текущем порядке — следующая страница
query.select(shape) // какие поля отвечать
await query // rows[]
await query.first() // row | null
await query.count() // number
await query.aggregate({ $sum: "views" }) // { sum }; также $avg, $min, $max — null, когда строк нетЗапрос — это значение: сохрани один и строй на нём.
const newest = db.post.where({ published: true }).order("id", "desc")
const page1 = await newest.limit(20)
const page2 = await newest.limit(20).after(page1[page1.length - 1].id)запрос выполняется через await. У него есть then и ничего больше от промиса — никакого
.catch(). Оборачивай await в try / catch.
Что содержит строка
Без select строка — это её id и её простые поля. Связи и структуры не читаются:
await db.post.first()
// { id: 1, title: "Hello", views: 10, tags: ["intro"], … } без author, без comments, без coverselect называет, что читать, и тип строки следует за ним:
const post = await db.post.where({ id }).select({
title: true,
cover: true, // структура целиком
author: { name: true }, // поле связанной строки
comments: { text: true, author: { name: true } },
}).first()
// { title: "Hello", cover: null, author: { name: "Ann" },
// comments: [{ text: "Nice", author: { name: "Bob" } }, …] }author: trueчитает id и простые поля связанной строки.- Как только ты передал
select, читается только то, что он называет — добавьid: true, если нужен id.
Связь принимает и запрос своей модели — чтобы отфильтровать, отсортировать, обрезать или посчитать связанные строки:
const authors = await db.author.select({
name: true,
posts: db.post.where({ published: true }).order("views", "desc").limit(3).select({ title: true }),
})
// [{ name: "Ann", posts: [{ title: "Hello" }] }, …]
const posts = await db.post.select({ title: true, comments: db.comment.count() })
// [{ title: "Hello", comments: { count: 2 } }, …]Фильтры
Каждая строка — фильтр какой-то модели; поля — те, что есть у этой модели.
where({ published: true }) // равно
where({ views: { $gte: 5, $lt: 100 } }) // $gt $gte $lt $lte — числа и даты
where({ role: { $in: ["editor", "admin"] } }) // $in $notIn $ne
where({ title: { $startsWith: "He" } }) // $startsWith $includes — строки
where({ rating: null }) // равно null; { $ne: null } — не null
where({ text: { $search: "fox" } }) // поле .fulltext(), по словам
where({ tags: ["intro", "news"] }) // поле-массив: равно целиком (без фильтра по одному элементу)
where({ author: { id: userId } }) // по связанной строке…
where({ author: { name: "Ann" } }) // …по любому её полю
where({ editor: null }) // нет связанной строки
where({ comments: { $some: { text: "Nice" } } }) // $some $every $none — строки с такими комментариями
where({ comments: { $none: {} } }) // строки вовсе без комментариев
where({ cover: { width: { $gte: 800 } } }) // внутрь структуры
where({ meta: { source: "import" } }) // внутрь поля t.json()…
where({ meta: { "stats.words": { $gt: 100 } } }) // …по пути через точку
where({ $or: [{ views: { $gt: 100 } }, { pinned: true }] }) // $or $and $not- Условия одного объекта объединяются через AND. Операторы на одном поле — тоже.
$everyверен и для строки, у которой связанных строк нет вовсе.$or,$andи$notстоят в своём объекте одни. Условие рядом с ними — ошибка типа: положи его внутрь или во второй.where().
Запись
const { id } = await db.post.insert({ title: "Hello", author: { id: authorId } })
await db.post.update(id, { title: "Hello, world" })
await db.post.delete(id)
const changed = await db.post.where({ published: false }).updateMany({ published: true }) // сколько
const removed = await db.comment.where({ post: { id } }).deleteMany() // сколькоinsertотвечает{ id }— не строкой. Поля с default заполняет база: прочитай строку обратно, если они нужны.- Связь пишется как
{ id }связанной строки. То, что ответилinsert, подходит как есть:db.post.insert({ title, author: await db.author.insert({ … }) }). updateиdeleteнесуществующего id ничего не делают и не бросают. Чтобы узнать, была ли строка, пиши через фильтр и читай счётчик:where({ id }).deleteMany().deleteMany()безwhereбросает. Чтобы опустошить таблицу, скажи это явно:.where({}).deleteMany().updateManyменяет только простые поля — связь меняется черезupdate, строка за строкой.
Что update принимает помимо простых значений:
await db.post.update(id, {
views: { $increment: 1 }, // число, изменённое на месте: без чтения, безопасно при двух писателях
tags: { $push: "news" }, // массив: $push, $pushUnique, $remove, $set
editor: { $connect: { id: editorId } }, // t.one: направить на другую строку…
cover: { $update: { width: 1024 } }, // структура: изменить часть её полей
})
await db.post.update(id, { editor: null }) // …или ни на какую (у optional)
await db.playlist.update(id, { items: { $connect: { id: postId } } }) // t.list: добавить в конец
await db.playlist.update(id, { items: { $set: [{ id: 3 }, { id: 1 }] } }) // заменить — и переупорядочитьКогда запись отклонена
Значение, нарушающее .unique(), или удаление, которое запрещает onDelete("restrict"), бросает.
Непойманная ошибка доходит до приложения как 500 Internal error, так что превращай те, которые
может вызвать человек, в ApiError:
export async function addAuthor(name: string, email: string) {
if (await db.author.where({ email }).first()) throw new ApiError(409, "This email is taken")
return db.author.insert({ name, email })
}Несколько записей как одна
const [author, post] = await db.$transaction([
db.author.insert({ name: "Cy", email: "cy@example.com" }),
db.post.insert({ title: "First", author: { id: ref("0.id") } }),
])
// author = { id: 4 }, post = { id: 9 }- Передавай запросы без
await. Выполняются все — или, когда один бросает, ни один. - Ответы приходят в том же порядке.
ref("0.id")— этоid, которым ответит элемент 0: так поздняя запись использует строку, которую вставляет ранняя.
Изменение моделей
Отредактируй модели и сохрани: база следует за ними. Добавление или удаление модели или поля сохраняет существующие строки.
поле, добавленное позже, равно null в строках, вставленных до него — даже когда оно
обязательно или имеет default, что бы ни говорил его тип. Default заполняет только новые строки.
Добавляй поле в таблицу, где уже есть строки, как .optional(), и читай null как «не задано».
Поле, меняющее тип, подхватить нельзя (String → Int). Под lecodes dev база тогда стартует
пустой и сообщает об этом в терминале; старая сохраняется рядом как .lecodes/db-old.
Вместо этого добавь поле под новым именем и перенеси значения кодом.
Подводные камни
const post = db.post.where({ id }).first() // ✗ запрос, а не строка — ничего не выполнилось
const post = await db.post.where({ id }).first() // ✓
await db.post.insert({ title, author: authorId }) // ✗ ошибка типа: связь — не голый id
await db.post.insert({ title, author: { id: authorId } }) // ✓
const { id, createdAt } = await db.task.insert({ title }) // ✗ insert отвечает только { id }
const { id } = await db.task.insert({ title }) // ✓ затем прочитай строку, если нужна
await db.post.select({ title: true }).where({ $or: [a, b], published: true }) // ✗ ошибка типа
await db.post.select({ title: true }).where({ $or: [a, b] }).where({ published: true }) // ✓
let visits = 0 // ✗ переменная модуля пропадёт при следующем перезапуске
await db.counter.update(1, { visits: { $increment: 1 } }) // ✓Смотрите также
- Серверные функции — эндпоинты, каналы, где работает бэкенд
- Вход — аккаунты поверх модели этой базы
- Хранимые файлы —
t.file(): загрузки, хранимые вместе со строками - Хранилище —
localStorageна устройстве