База данных

У бэкенда проекта есть база данных. Серверный код (*.server.ts, см. Серверные функции) описывает её таблицы как модели и читает и пишет их через типизированный db. Нет ни SQL, ни строки подключения, ни файла миграции: модели в коде — это и есть схема.

База данных есть только на сервере. Приложение добирается до неё через эндпоинты; для данных, которые остаются на устройстве, используй localStorage.

Обзор

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

Модели

TypeScript
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) — Хранимые файлы
TypeScript
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([]), чтобы его можно было опустить.

Связи

TypeScript
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 — список, который ты упорядочиваешь вручную: плейлист, рейтинг. Он пишется с этой стороны.
TypeScript
t.one("Post").onDelete("cascade")    // пост удалён → эта строка удаляется вместе с ним
t.one("Author").onDelete("setNull")  // автор удалён → это поле становится null (по умолчанию для optional)
t.one("Author").onDelete("restrict") // автора нельзя удалить, пока на него указывает эта строка (по умолчанию для обязательного)

С моделями выше удаление автора удаляет его посты и комментарии к этим постам, очищает editor у постов, которые он редактировал, и отклоняется, пока остаётся его комментарий — сначала удали их.

Чтение

db.author — запрос всех авторов. Каждый метод отвечает новым запросом; await его выполняет.

TypeScript
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)
TypeScript
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, когда строк нет

Запрос — это значение: сохрани один и строй на нём.

TypeScript
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)
Note

запрос выполняется через await. У него есть then и ничего больше от промиса — никакого .catch(). Оборачивай await в try / catch.

Что содержит строка

Без select строка — это её id и её простые поля. Связи и структуры не читаются:

TypeScript
await db.post.first()
// { id: 1, title: "Hello", views: 10, tags: ["intro"], … }      без author, без comments, без cover

select называет, что читать, и тип строки следует за ним:

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

Связь принимает и запрос своей модели — чтобы отфильтровать, отсортировать, обрезать или посчитать связанные строки:

TypeScript
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 } }, …]

Фильтры

Каждая строка — фильтр какой-то модели; поля — те, что есть у этой модели.

TypeScript
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().

Запись

TypeScript
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 принимает помимо простых значений:

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

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

Несколько записей как одна

TypeScript
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: так поздняя запись использует строку, которую вставляет ранняя.

Изменение моделей

Отредактируй модели и сохрани: база следует за ними. Добавление или удаление модели или поля сохраняет существующие строки.

Note

поле, добавленное позже, равно null в строках, вставленных до него — даже когда оно обязательно или имеет default, что бы ни говорил его тип. Default заполняет только новые строки. Добавляй поле в таблицу, где уже есть строки, как .optional(), и читай null как «не задано».

Поле, меняющее тип, подхватить нельзя (String → Int). Под lecodes dev база тогда стартует пустой и сообщает об этом в терминале; старая сохраняется рядом как .lecodes/db-old. Вместо этого добавь поле под новым именем и перенеси значения кодом.

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

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

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