{
  "version": "1.0",
  "title": "Roadmap backend-разработчика",
  "nodes": [
    {
      "id": "start",
      "label": "Backend",
      "type": "core",
      "area": "start",
      "x": 0.5,
      "y": 0.5,
      "locked": false,
      "sections": [
        {
          "id": "backend-welcome",
          "kind": "lecture",
          "title": "Ваш маршрут и итоговый проект",
          "desc": "20 практических шагов: HTTP, Node.js, PostgreSQL, доступ к данным, тесты и выпуск собственного API.",
          "content": "## Что вы построите\n\nTaskboard — API доски задач. Пользователь создаёт проект, добавляет задачи, меняет их статус и видит только свои данные. К концу маршрута сервис переживает перезапуск, проверяется тестами и запускается из контейнера.\n\n## Что нужно на старте\n\nНужны переменные, функции, массивы и объекты JavaScript, базовое понимание async/await. Установите поддерживаемую LTS-версию Node.js, Git и PostgreSQL либо Docker для локальной базы. Первые шаги работают без платных сервисов. Примеры используют JavaScript ESM; TypeScript можно добавить после работающего вертикального сценария.\n\n## Как учиться\n\nИдите по номерам: каждые пять шагов дают очередную часть проекта. Внутри каждого узла — объяснение, разобранный пример, практическое задание с критериями и вопрос на понимание. Пример показывает приём; полноценное решение вы собираете в своём проекте.\n\nПрактику можно подтвердить самостоятельно; подключение ИИ к Loono не требуется. Если внешний инструмент платный, используйте описанный тестовый ответ или ручной сценарий, а сетевую проверку выполните отдельно.\n\n- 1. Первый работающий HTTP-сервис\n- 2. Данные и корректность\n- 3. Пользователи и надёжность\n- 4. Проверки и выпуск\n\nМатериалы подготовлены Loono. Дата редакции: 2026-09-07. Источники указаны рядом с уроками."
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-scope",
      "label": "01. Границы сервиса",
      "area": "infra",
      "type": "large",
      "x": 0.28206959172717117,
      "y": 0.426472201515086,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-scope-reading",
          "kind": "lecture",
          "title": "Границы сервиса",
          "desc": "Описать один законченный пользовательский сценарий.",
          "content": "Backend хранит данные и выполняет правила, которые нельзя доверять браузеру. Начните с поведения: пользователь создаёт задачу, читает её и завершает. Это вертикальный срез: один запрос проходит через проверку входа, бизнес-правило и хранилище.\n\nДля первого выпуска Taskboard достаточно проектов и задач. Чат, совместное редактирование и рекомендации не нужны, чтобы проверить основной сценарий. Запишите также запрещённое поведение: чужой пользователь не может прочитать задачу; пустое название не сохраняется; повторный запрос не должен незаметно создавать дубли там, где обещана идемпотентность.\n\nТребование становится полезным, когда можно предъявить наблюдаемый результат. «Быстрый и безопасный» не помогает написать тест. «Чужая задача не возвращается по её идентификатору» уже помогает.\n\n## Разбор на примере\n\nСценарий: Аня создаёт проект «Портфолио», добавляет задачу «Опубликовать API», завершает её. Борис получает отказ при запросе этой задачи. Это четыре проверяемых действия, а не список технологий.\n\n## Источники и следующий шаг\n\n- [MDN: HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-scope-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Напишите краткое описание Taskboard: кто пользователь, какие три действия доступны и какие три ситуации запрещены. Зафиксируйте то, что не входит в первую версию.",
          "task": "Напишите краткое описание Taskboard: кто пользователь, какие три действия доступны и какие три ситуации запрещены. Зафиксируйте то, что не входит в первую версию.",
          "content": "## Задание\n\nНапишите краткое описание Taskboard: кто пользователь, какие три действия доступны и какие три ситуации запрещены. Зафиксируйте то, что не входит в первую версию.\n\n## Критерии готовности\n\n- У каждого действия есть вход и наблюдаемый результат.\n- Есть отдельный сценарий доступа к чужой задаче.\n- Первая версия не зависит от чата, платежей и микросервисов.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-scope-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Какое требование можно сразу превратить в тест?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Сервис должен быть современным",
              "feedback": "Неверно. Проверяемое правило задаёт конкретную попытку и ожидаемый результат."
            },
            {
              "text": "Пользователь не может получить чужую задачу по ID",
              "feedback": "Верно. Проверяемое правило задаёт конкретную попытку и ожидаемый результат."
            },
            {
              "text": "Нужно использовать много технологий",
              "feedback": "Неверно. Проверяемое правило задаёт конкретную попытку и ожидаемый результат."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-setup",
      "label": "02. Проект и Git",
      "area": "infra",
      "type": "medium",
      "x": 0.2680344761995511,
      "y": 0.3560521074555678,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-setup-reading",
          "kind": "lecture",
          "title": "Проект и Git",
          "desc": "Поднять воспроизводимое окружение.",
          "content": "Репозиторий должен объяснять, как запустить проект на другой машине. Храните исходники, lockfile, инструкции и пример конфигурации без настоящих секретов. node_modules и локальная конфигурация в историю не входят. Маленький коммит соответствует одному понятному изменению: так проще найти поломку и отменить конкретную правку.\n\nВ package.json задайте type: module и команду start, запускающую src/server.js. Версию Node.js запишите в README. При первом добавлении зависимостей npm install создаёт lockfile; для воспроизведения окружения используйте npm ci. Не обновляйте все библиотеки одновременно с первой функциональной задачей: иначе неясно, какое изменение вызвало проблему.\n\n## Разбор на примере\n\nСтруктура:\n```text\ntaskboard/\n  src/server.js\n  test/\n  package.json\n  package-lock.json\n  .gitignore\n  .env.example\n  README.md\n```\nВ .gitignore добавьте node_modules/, .env и coverage/. Перед коммитом посмотрите git diff --staged.\n\n## Источники и следующий шаг\n\n- [Node.js: основы](https://nodejs.org/en/learn/getting-started/introduction-to-nodejs)\n- [Git: основы](https://git-scm.com/book/en/v2/Getting-Started-Git-Basics)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-setup-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Создайте репозиторий Taskboard. Добавьте команду запуска и README, затем воспроизведите запуск из отдельной копии проекта.",
          "task": "Создайте репозиторий Taskboard. Добавьте команду запуска и README, затем воспроизведите запуск из отдельной копии проекта.",
          "content": "## Задание\n\nСоздайте репозиторий Taskboard. Добавьте команду запуска и README, затем воспроизведите запуск из отдельной копии проекта.\n\n## Критерии готовности\n\n- npm start имеет один документированный результат.\n- В Git нет .env и node_modules.\n- Из чистой копии зависимости устанавливаются по lockfile.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-setup-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что помогает воспроизводить набор зависимостей?",
          "correctAnswer": 2,
          "options": [
            {
              "text": "Только папка node_modules",
              "feedback": "Неверно. Lockfile фиксирует разрешённые версии; npm ci устанавливает их без произвольного обновления."
            },
            {
              "text": "Обновление всех пакетов при запуске",
              "feedback": "Неверно. Lockfile фиксирует разрешённые версии; npm ci устанавливает их без произвольного обновления."
            },
            {
              "text": "Закоммиченный lockfile и npm ci",
              "feedback": "Верно. Lockfile фиксирует разрешённые версии; npm ci устанавливает их без произвольного обновления."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-http",
      "label": "03. Запрос и ответ",
      "area": "infra",
      "type": "medium",
      "x": 0.276554257145051,
      "y": 0.276554257145051,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-http-reading",
          "kind": "lecture",
          "title": "Запрос и ответ",
          "desc": "Различать транспорт, маршрут и результат операции.",
          "content": "HTTP-запрос содержит метод, адрес, заголовки и иногда тело. GET читает представление ресурса; POST обычно создаёт ресурс или запускает действие; PATCH меняет часть состояния. Сервер отвечает статусом, заголовками и телом. Content-Type объясняет формат тела, а не гарантирует его корректность.\n\nРазделяйте сетевой сбой и ответ приложения. При 404 сервер ответил, но ресурс не найден. При тайм-ауте клиент может не знать, успела ли операция выполниться. Поэтому автоматический повтор записи требует отдельного решения. Не возвращайте 200 для любого исхода: клиенту и мониторингу тогда сложно отличить успех от отказа.\n\n![Путь HTTP-запроса: клиент, проверка входа, бизнес-правила и хранилище; ответ возвращается клиенту.](/course-images/backend-request-v1.webp \"Одна мастерская — один backend. Синяя линия показывает запрос, золотая — ответ. Рабочие места обозначают обязанности кода, а не отдельные микросервисы. 201 — пример ответа при создании ресурса, не универсальный статус успеха.\")\n\n## Разбор на примере\n\n```http\nPOST /tasks HTTP/1.1\nContent-Type: application/json\n\n{\"title\":\"Опубликовать API\"}\n```\nУспех: 201 и созданная задача. Пустое название: 400 с кодом ошибки. Несуществующий маршрут: 404. Отказ в доступе не превращается в пустой успешный объект.\n\n## Источники и следующий шаг\n\n- [MDN: HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-http-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Составьте таблицу методов, адресов и статусов для создания, чтения, изменения и удаления задач. Выполните GET через curl.exe или HTTP-клиент.",
          "task": "Составьте таблицу методов, адресов и статусов для создания, чтения, изменения и удаления задач. Выполните GET через curl.exe или HTTP-клиент.",
          "content": "## Задание\n\nСоставьте таблицу методов, адресов и статусов для создания, чтения, изменения и удаления задач. Выполните GET через curl.exe или HTTP-клиент.\n\n## Критерии готовности\n\n- Есть отдельные исходы 201, 400 и 404.\n- Вы можете объяснить разницу между тайм-аутом и HTTP-ошибкой.\n- Повтор POST не объявлен безопасным без реализации.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-http-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Клиент получил тайм-аут после POST. Что известно о записи?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Она точно не произошла",
              "feedback": "Неверно. Потеря ответа не доказывает отсутствие побочного эффекта."
            },
            {
              "text": "Она могла произойти до потери ответа",
              "feedback": "Верно. Потеря ответа не доказывает отсутствие побочного эффекта."
            },
            {
              "text": "Сервер обязательно удалит её",
              "feedback": "Неверно. Потеря ответа не доказывает отсутствие побочного эффекта."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-server",
      "label": "04. Первый сервер",
      "area": "infra",
      "type": "medium",
      "x": 0.3107058848957834,
      "y": 0.19496108774959287,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-server-reading",
          "kind": "lecture",
          "title": "Первый сервер",
          "desc": "Запустить и проверить небольшой HTTP-обработчик.",
          "content": "Начните со стандартного HTTP-модуля, чтобы увидеть границу между запросом и ответом без скрытой магии фреймворка. Обработчик вызывается для каждого запроса. Ответ нужно завершить; иначе клиент продолжит ждать. Разбор URL нужен, чтобы параметры запроса не смешивались с путём.\n\nНа этом шаге сервис локальный и отвечает только на health. Это не показатель готовности базы и не полноценный бизнес-сценарий. Unknown route обязан возвращать 404. После проверки можно выбрать привычный фреймворк, сохранив тот же внешний контракт. Не открывайте учебный сервер в интернет до реализации доступа и ограничений.\n\n## Разбор на примере\n\nСохраните в src/server.js:\n```javascript\nimport {createServer} from \"node:http\";\nconst server = createServer((req, res) => {\n  const path = new URL(req.url, \"http://localhost\").pathname;\n  const ok = req.method === \"GET\" && path === \"/health\";\n  res.writeHead(ok ? 200 : 404, {\"Content-Type\": \"application/json\"});\n  res.end(JSON.stringify(ok ? {status: \"ok\"} : {error: \"not_found\"}));\n});\nserver.listen(3000, \"127.0.0.1\");\n```\n\n## Источники и следующий шаг\n\n- [Node.js: основы](https://nodejs.org/en/learn/getting-started/introduction-to-nodejs)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-server-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Запустите сервер и проверьте /health, /missing и POST /health. Добавьте автоматизированную или записанную ручную проверку статусов.",
          "task": "Запустите сервер и проверьте /health, /missing и POST /health. Добавьте автоматизированную или записанную ручную проверку статусов.",
          "content": "## Задание\n\nЗапустите сервер и проверьте /health, /missing и POST /health. Добавьте автоматизированную или записанную ручную проверку статусов.\n\n## Критерии готовности\n\n- /health отвечает 200 с JSON.\n- Неизвестный путь и неподдерживаемый метод не получают успешный ответ.\n- После остановки процесса порт освобождается.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-server-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что означает успешный /health в этом примере?",
          "correctAnswer": 2,
          "options": [
            {
              "text": "База и все операции проверены",
              "feedback": "Неверно. Пример проверяет только процесс и обработчик; зависимости ещё не участвуют."
            },
            {
              "text": "Пользователь авторизован",
              "feedback": "Неверно. Пример проверяет только процесс и обработчик; зависимости ещё не участвуют."
            },
            {
              "text": "HTTP-процесс отвечает на конкретный маршрут",
              "feedback": "Верно. Пример проверяет только процесс и обработчик; зависимости ещё не участвуют."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-contract",
      "label": "05. Контракт и валидация",
      "area": "infra",
      "type": "medium",
      "x": 0.37148619569158514,
      "y": 0.11909554727966426,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-contract-reading",
          "kind": "lecture",
          "title": "Контракт и валидация",
          "desc": "Не пропускать произвольные данные в бизнес-логику.",
          "content": "Клиентские данные считаются непроверенными даже при наличии формы с обязательными полями. На сервере проверьте форму объекта, типы, границы длины и допустимые значения. Установите ограничение размера тела до накопления его целиком. Невалидный JSON и валидный JSON неправильной формы — разные случаи, но оба не должны падать в необработанную ошибку.\n\nНе присваивайте поля тела объекту базы целиком: злоумышленник может прислать ownerId или служебный статус. Явно выберите разрешённые поля. Проверку формы отделяйте от правил: «title — строка» относится к входу, «задача принадлежит этому проекту» требует данных.\n\n## Разбор на примере\n\nЧистая функция для первого POST:\n```javascript\nexport function parseTask(body) {\n  if (!body || typeof body.title !== \"string\") throw new Error(\"invalid_title\");\n  const title = body.title.trim();\n  if (!title || title.length > 120) throw new Error(\"invalid_title\");\n  return {title};\n}\n```\nHTTP-слой преобразует известную ошибку в 400, а неизвестную — в 500 без внутренних подробностей.\n\n## Источники и следующий шаг\n\n- [OWASP: рекомендации по безопасности](https://cheatsheetseries.owasp.org/)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-contract-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте POST /tasks с локальным массивом и parseTask. Проверьте null, число, пустую строку, слишком длинное название и посторонний ownerId.",
          "task": "Добавьте POST /tasks с локальным массивом и parseTask. Проверьте null, число, пустую строку, слишком длинное название и посторонний ownerId.",
          "content": "## Задание\n\nДобавьте POST /tasks с локальным массивом и parseTask. Проверьте null, число, пустую строку, слишком длинное название и посторонний ownerId.\n\n## Критерии готовности\n\n- Созданной задаче сервер присваивает ID.\n- В ответ не попадают произвольные поля клиента.\n- Неправильный ввод не завершает процесс.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-contract-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Откуда брать ownerId при создании задачи?",
          "correctAnswer": 0,
          "options": [
            {
              "text": "Из проверенной сервером сессии",
              "feedback": "Верно. Клиент выбирает содержание операции, но не свою подтверждённую идентичность."
            },
            {
              "text": "Из JSON клиента",
              "feedback": "Неверно. Клиент выбирает содержание операции, но не свою подтверждённую идентичность."
            },
            {
              "text": "Из названия проекта",
              "feedback": "Неверно. Клиент выбирает содержание операции, но не свою подтверждённую идентичность."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-model",
      "label": "06. Модель данных",
      "area": "data",
      "type": "large",
      "x": 0.573527798484914,
      "y": 0.28206959172717117,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-model-reading",
          "kind": "lecture",
          "title": "Модель данных",
          "desc": "Превратить сценарий в таблицы и ограничения.",
          "content": "Массив теряется при перезапуске. Постоянное хранилище задаёт структуру и защищает инварианты независимо от конкретного обработчика. В Taskboard задача принадлежит проекту, проект — пользователю. Внешний ключ запрещает ссылку на отсутствующий проект, NOT NULL запрещает отсутствие обязательного значения, CHECK ограничивает статус.\n\nНе храните одно и то же правило только в JavaScript: при конкурентных запросах две проверки могут пройти одновременно. Уникальность должна обеспечиваться базой, если нарушение недопустимо. Начните с минимальных таблиц. Поля «на всякий случай» усложняют миграции и маскируют неясную модель.\n\n## Разбор на примере\n\nФрагмент схемы после создания projects:\n```sql\nCREATE TABLE tasks (\n  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n  project_id bigint NOT NULL REFERENCES projects(id),\n  title text NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 120),\n  status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'done')),\n  created_at timestamptz NOT NULL DEFAULT now()\n);\n```\n\n## Источники и следующий шаг\n\n- [PostgreSQL: учебник](https://www.postgresql.org/docs/current/tutorial.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-model-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Спроектируйте users, projects и tasks. Создайте таблицы в локальной базе и попробуйте вставить задачу с несуществующим project_id.",
          "task": "Спроектируйте users, projects и tasks. Создайте таблицы в локальной базе и попробуйте вставить задачу с несуществующим project_id.",
          "content": "## Задание\n\nСпроектируйте users, projects и tasks. Создайте таблицы в локальной базе и попробуйте вставить задачу с несуществующим project_id.\n\n## Критерии готовности\n\n- Все таблицы имеют первичные ключи.\n- Несуществующий проект и недопустимый статус отклоняются базой.\n- Вы объясняете, что происходит с задачами при удалении проекта.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-model-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что защищает от ссылки на несуществующий проект?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Название столбца project_id",
              "feedback": "Неверно. Именно ограничение FOREIGN KEY проверяет существование связанной записи."
            },
            {
              "text": "Внешний ключ",
              "feedback": "Верно. Именно ограничение FOREIGN KEY проверяет существование связанной записи."
            },
            {
              "text": "Комментарий в README",
              "feedback": "Неверно. Именно ограничение FOREIGN KEY проверяет существование связанной записи."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-queries",
      "label": "07. SQL без склейки строк",
      "area": "data",
      "type": "medium",
      "x": 0.6439478925444322,
      "y": 0.2680344761995511,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-queries-reading",
          "kind": "lecture",
          "title": "SQL без склейки строк",
          "desc": "Подключить хранилище и безопасно передавать параметры.",
          "content": "SQL описывает нужные данные, а планировщик выбирает способ их получить. Значения пользователя передавайте параметрами, не собирайте запрос конкатенацией. Это отделяет синтаксис SQL от данных. Названия таблиц и направление сортировки обычно не параметризуются как значения: для них нужен заранее заданный список вариантов.\n\nИспользуйте пул соединений. Отдельное новое подключение на каждый запрос дорого; бесконечный пул способен исчерпать ресурсы базы. HTTP-обработчик вызывает функцию репозитория и преобразует результат в ответ, а не знает детали подключения. Пароль базы хранится в локальной конфигурации, не в файле исходников.\n\n## Разбор на примере\n\nПосле npm install pg настройте DATABASE_URL локально:\n```javascript\nimport pg from \"pg\";\nconst pool = new pg.Pool({connectionString: process.env.DATABASE_URL, max: 5});\nconst result = await pool.query(\n  \"SELECT id, title, status FROM tasks WHERE project_id = $1 ORDER BY id LIMIT $2\",\n  [projectId, 20],\n);\n```\nprojectId здесь уже получен из проверенного ввода и проверенного доступа к проекту.\n\n## Источники и следующий шаг\n\n- [PostgreSQL: учебник](https://www.postgresql.org/docs/current/tutorial.html)\n- [node-postgres: запросы](https://node-postgres.com/features/queries)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-queries-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Замените массив на PostgreSQL. Добавьте создание и чтение задач параметризованными запросами. Перезапустите Node.js и повторите чтение.",
          "task": "Замените массив на PostgreSQL. Добавьте создание и чтение задач параметризованными запросами. Перезапустите Node.js и повторите чтение.",
          "content": "## Задание\n\nЗамените массив на PostgreSQL. Добавьте создание и чтение задач параметризованными запросами. Перезапустите Node.js и повторите чтение.\n\n## Критерии готовности\n\n- Данные переживают перезапуск приложения.\n- Кавычка в названии не ломает запрос.\n- Соединения переиспользуются и корректно закрываются в тестах.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-queries-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Как передавать пользовательское название задачи в SQL?",
          "correctAnswer": 2,
          "options": [
            {
              "text": "Вставить в строку запроса",
              "feedback": "Неверно. Параметры сохраняют различие между текстом запроса и пользовательскими значениями."
            },
            {
              "text": "Удалить кавычки и вставить",
              "feedback": "Неверно. Параметры сохраняют различие между текстом запроса и пользовательскими значениями."
            },
            {
              "text": "Передать отдельным параметром",
              "feedback": "Верно. Параметры сохраняют различие между текстом запроса и пользовательскими значениями."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-migrations",
      "label": "08. Миграции схемы",
      "area": "data",
      "type": "medium",
      "x": 0.723445742854949,
      "y": 0.276554257145051,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-migrations-reading",
          "kind": "lecture",
          "title": "Миграции схемы",
          "desc": "Изменять базу воспроизводимо.",
          "content": "Изменение таблицы вручную работает на одной машине, но не объясняет состояние остальных окружений. Миграция — упорядоченное изменение схемы с записью о применении. Уже выполненные миграции не переписывают: новое требование оформляют следующим шагом. Тестируйте переход и на пустой базе, и на базе с существующими записями.\n\nДобавление обязательного столбца требует значения для старых строк. Для крупной таблицы заполнение и ограничение часто разделяют. Откат кода не обязательно означает откат данных: удаление столбца может уничтожить информацию. Совместимые расширения схемы позволяют временно работать старой и новой версии приложения.\n\n## Разбор на примере\n\nДля учебной небольшой базы:\n```sql\nALTER TABLE tasks ADD COLUMN priority integer DEFAULT 0;\nUPDATE tasks SET priority = 0 WHERE priority IS NULL;\nALTER TABLE tasks ALTER COLUMN priority SET NOT NULL;\nALTER TABLE tasks ADD CONSTRAINT task_priority CHECK (priority BETWEEN 0 AND 3);\n```\nВ журнале миграций хранится идентификатор этого изменения. Не запускайте такой файл заново при каждом HTTP-запросе.\n\n## Источники и следующий шаг\n\n- [PostgreSQL: учебник](https://www.postgresql.org/docs/current/tutorial.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-migrations-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте priority миграцией. Проверьте старые записи и настройте однократное применение через выбранный инструмент миграций или небольшой журнал версий.",
          "task": "Добавьте priority миграцией. Проверьте старые записи и настройте однократное применение через выбранный инструмент миграций или небольшой журнал версий.",
          "content": "## Задание\n\nДобавьте priority миграцией. Проверьте старые записи и настройте однократное применение через выбранный инструмент миграций или небольшой журнал версий.\n\n## Критерии готовности\n\n- Старая задача получила допустимое значение.\n- Чистая база создаётся всей цепочкой миграций.\n- Повторный запуск не пытается добавить столбец второй раз.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-migrations-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "После публикации изменилась схема. Как исправить прошлую миграцию?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Удалить историю базы",
              "feedback": "Неверно. Следующая миграция делает переход явным для всех окружений."
            },
            {
              "text": "Добавить следующую миграцию",
              "feedback": "Верно. Следующая миграция делает переход явным для всех окружений."
            },
            {
              "text": "Переписать уже применённый файл",
              "feedback": "Неверно. Следующая миграция делает переход явным для всех окружений."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-transactions",
      "label": "09. Транзакции и гонки",
      "area": "data",
      "type": "medium",
      "x": 0.8050389122504071,
      "y": 0.3107058848957833,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-transactions-reading",
          "kind": "lecture",
          "title": "Транзакции и гонки",
          "desc": "Сохранять связанные изменения вместе.",
          "content": "Транзакция объединяет операции: либо все изменения фиксируются, либо откатываются. Это нужно, когда завершение задачи создаёт запись аудита. Если первая запись прошла, а вторая нет, история расходится с состоянием. Все команды транзакции выполняются на одном соединении. Вызовы pool.query могут взять разные соединения и не образуют одну транзакцию.\n\nТранзакция сама по себе не устраняет любую гонку. Два клиента могут прочитать старое состояние. Для перехода todo → done используйте условный UPDATE и проверяйте количество изменённых строк. Победитель записывает событие, повторный вызов возвращает уже достигнутое состояние. Внешнее письмо не откатится вместе с SQL: его отправку выносите за границу транзакции.\n\n## Разбор на примере\n\nПоследовательность на одном клиенте:\n```sql\nBEGIN;\nUPDATE tasks SET status = 'done' WHERE id = 42 AND status = 'todo' RETURNING id;\n-- Только при изменённой строке: INSERT в task_events.\nCOMMIT;\n```\nПри ошибке выполните ROLLBACK; release соединения — в finally. Проверка владельца должна входить в операцию или предшествующий проверенный контекст.\n\n## Источники и следующий шаг\n\n- [PostgreSQL: транзакции](https://www.postgresql.org/docs/current/tutorial-transactions.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-transactions-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте аудит завершений. Смоделируйте ошибку вставки события и два одновременных завершения одной задачи.",
          "task": "Добавьте аудит завершений. Смоделируйте ошибку вставки события и два одновременных завершения одной задачи.",
          "content": "## Задание\n\nДобавьте аудит завершений. Смоделируйте ошибку вставки события и два одновременных завершения одной задачи.\n\n## Критерии готовности\n\n- При ошибке аудита изменение статуса откатывается.\n- Два запроса не создают два события одного перехода.\n- Соединение освобождается и при исключении.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-transactions-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Где должны исполняться команды одной транзакции?",
          "correctAnswer": 0,
          "options": [
            {
              "text": "На одном соединении",
              "feedback": "Верно. Состояние транзакции принадлежит соединению с PostgreSQL."
            },
            {
              "text": "На любом соединении пула",
              "feedback": "Неверно. Состояние транзакции принадлежит соединению с PostgreSQL."
            },
            {
              "text": "На разных соединениях для скорости",
              "feedback": "Неверно. Состояние транзакции принадлежит соединению с PostgreSQL."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-pagination",
      "label": "10. Списки и индексы",
      "area": "data",
      "type": "medium",
      "x": 0.8809044527203358,
      "y": 0.37148619569158514,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-pagination-reading",
          "kind": "lecture",
          "title": "Списки и индексы",
          "desc": "Не возвращать всю базу одним ответом.",
          "content": "Неограниченный список работает на десяти задачах и ломается на ста тысячах. Ограничьте размер страницы на сервере. Сортировка должна быть стабильной: created_at может совпадать, поэтому нужен второй ключ. Для начала удобно идти по возрастающему id и передавать последний увиденный ID как курсор.\n\nИндекс ускоряет конкретный способ поиска, но занимает место и удорожает запись. Для списка задач проекта полезен составной индекс (project_id, id). Проверяйте план на репрезентативном объёме: последовательное чтение маленькой таблицы не означает поломку индекса. OFFSET прост для небольших списков, но глубокие страницы требуют пропустить много строк.\n\n## Разбор на примере\n\n```sql\nCREATE INDEX tasks_project_cursor ON tasks(project_id, id);\nSELECT id, title, status FROM tasks\nWHERE project_id = $1 AND id > $2\nORDER BY id LIMIT $3;\n```\nЗапросите limit + 1 строку, чтобы определить hasMore. Клиенту верните не больше limit и курсор последней возвращённой задачи.\n\n## Источники и следующий шаг\n\n- [PostgreSQL: учебник](https://www.postgresql.org/docs/current/tutorial.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-pagination-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте курсорную пагинацию с пределом 100. Проверьте первую, промежуточную и последнюю страницы на 250 задачах.",
          "task": "Добавьте курсорную пагинацию с пределом 100. Проверьте первую, промежуточную и последнюю страницы на 250 задачах.",
          "content": "## Задание\n\nДобавьте курсорную пагинацию с пределом 100. Проверьте первую, промежуточную и последнюю страницы на 250 задачах.\n\n## Критерии готовности\n\n- Задачи не повторяются при последовательном обходе.\n- Сервер ограничивает слишком большой limit.\n- Есть план запроса и объяснение выбранного индекса.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-pagination-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Почему created_at без дополнительного ключа опасен для курсора?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Дата всегда уникальна",
              "feedback": "Неверно. При одинаковом времени нужен детерминированный второй ключ, чтобы не пропускать записи."
            },
            {
              "text": "Несколько записей могут иметь одинаковое время",
              "feedback": "Верно. При одинаковом времени нужен детерминированный второй ключ, чтобы не пропускать записи."
            },
            {
              "text": "По дате нельзя сортировать",
              "feedback": "Неверно. При одинаковом времени нужен детерминированный второй ключ, чтобы не пропускать записи."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-sessions",
      "label": "11. Вход и сессия",
      "area": "auth",
      "type": "large",
      "x": 0.7179304082728288,
      "y": 0.573527798484914,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-sessions-reading",
          "kind": "lecture",
          "title": "Вход и сессия",
          "desc": "Отделить проверку пользователя от хранения его состояния.",
          "content": "Аутентификация отвечает, кто делает запрос. Для первого браузерного приложения удобно использовать непрозрачный случайный идентификатор сессии: браузер хранит cookie, сервер — пользователя и срок действия. Сессия должна прекращаться при выходе; после входа идентификатор обновляется. Пароли не хранят открыто и не защищают обычным SHA-256: используйте специализированную библиотеку хеширования паролей.\n\nCookie с HttpOnly недоступна JavaScript, Secure отправляет её по HTTPS, SameSite ограничивает часть межсайтовых сценариев. Эти флаги не заменяют проверку CSRF для выбранной схемы. Не реализуйте собственную криптографию. Для локальной практики сначала сделайте тестовый вход с фиксированным пользователем, явно недоступный в production, затем подключите библиотеку аутентификации.\n\n## Разбор на примере\n\nМодель: session_id → user_id, expires_at, revoked_at. Сервер проверяет срок и отзыв при каждом запросе. Logout отзывает запись и удаляет cookie. Сохранённая ранее cookie после logout больше не даёт доступ.\n\n## Источники и следующий шаг\n\n- [OWASP: Session Management](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-sessions-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте вход, выход и /me через выбранную библиотеку. Для учебной проверки используйте два локальных аккаунта и не отправляйте настоящие пароли в логи.",
          "task": "Добавьте вход, выход и /me через выбранную библиотеку. Для учебной проверки используйте два локальных аккаунта и не отправляйте настоящие пароли в логи.",
          "content": "## Задание\n\nДобавьте вход, выход и /me через выбранную библиотеку. Для учебной проверки используйте два локальных аккаунта и не отправляйте настоящие пароли в логи.\n\n## Критерии готовности\n\n- Без сессии /me отвечает 401.\n- Просроченная и отозванная сессии отклоняются.\n- Повторное использование cookie после выхода не восстанавливает доступ.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-sessions-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Достаточно ли удалить cookie только в браузере для отзыва серверной сессии?",
          "correctAnswer": 2,
          "options": [
            {
              "text": "Да, копии cookie невозможны",
              "feedback": "Неверно. Скопированный идентификатор должен перестать работать независимо от браузера."
            },
            {
              "text": "Да, если cookie длинная",
              "feedback": "Неверно. Скопированный идентификатор должен перестать работать независимо от браузера."
            },
            {
              "text": "Нет, нужно прекратить действие сессии на сервере",
              "feedback": "Верно. Скопированный идентификатор должен перестать работать независимо от браузера."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-authorization",
      "label": "12. Доступ к объектам",
      "area": "auth",
      "type": "medium",
      "x": 0.7319655238004489,
      "y": 0.6439478925444322,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-authorization-reading",
          "kind": "lecture",
          "title": "Доступ к объектам",
          "desc": "Закрыть чтение и изменение чужих задач.",
          "content": "Наличие сессии не даёт доступ ко всем объектам. Авторизация проверяет конкретное действие над конкретным ресурсом. Условие «пользователь залогинен» пропускает атаку, в которой ID задачи заменяют на чужой. UUID тоже не заменяет проверку: сложность угадывания не является правом доступа.\n\nВ Taskboard проверка проходит через владельца проекта. Применяйте её к чтению, обновлению, удалению и спискам. Если endpoint возвращает вложенные данные, проверяйте и их принадлежность. Можно отвечать 404 вместо 403, чтобы не раскрывать существование чужого объекта; важно делать это последовательно. Тест с двумя пользователями полезнее проверки только счастливого пути.\n\n## Разбор на примере\n\nЧтение задачи:\n```sql\nSELECT t.id, t.title, t.status FROM tasks t\nJOIN projects p ON p.id = t.project_id\nWHERE t.id = $1 AND p.owner_id = $2;\n```\n$2 берётся из сессии. Если строк нет, ответ — 404. Для UPDATE используйте такое же ограничение владельца, а не отдельный незащищённый запрос по id.\n\n## Источники и следующий шаг\n\n- [OWASP: рекомендации по безопасности](https://cheatsheetseries.owasp.org/)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-authorization-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Проверьте все операции Ани с задачами Бориса. Добавьте негативные тесты чтения, изменения и удаления.",
          "task": "Проверьте все операции Ани с задачами Бориса. Добавьте негативные тесты чтения, изменения и удаления.",
          "content": "## Задание\n\nПроверьте все операции Ани с задачами Бориса. Добавьте негативные тесты чтения, изменения и удаления.\n\n## Критерии готовности\n\n- ID владельца никогда не берётся из тела запроса.\n- Чужие задачи не видны в списках.\n- Изменение и удаление защищены так же, как чтение.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-authorization-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что защищает задачу с непредсказуемым UUID?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Только секретность UUID",
              "feedback": "Неверно. Сервер обязан проверять права независимо от формы идентификатора и интерфейса."
            },
            {
              "text": "Проверка права пользователя на объект",
              "feedback": "Верно. Сервер обязан проверять права независимо от формы идентификатора и интерфейса."
            },
            {
              "text": "Запрет кнопки в интерфейсе",
              "feedback": "Неверно. Сервер обязан проверять права независимо от формы идентификатора и интерфейса."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-errors",
      "label": "13. Ошибки и тайм-ауты",
      "area": "auth",
      "type": "medium",
      "x": 0.723445742854949,
      "y": 0.723445742854949,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-errors-reading",
          "kind": "lecture",
          "title": "Ошибки и тайм-ауты",
          "desc": "Дать клиенту понятный исход и ограничить ожидание.",
          "content": "Ошибка контракта, отсутствие ресурса и падение базы требуют разных действий. Верните клиенту стабильный code, короткое сообщение и requestId. Внутренний stack trace нужен разработчику в защищённом журнале, а не пользователю. Логи не должны содержать пароли, cookie и содержимое приватных задач.\n\nЛюбой внешний вызов ограничивайте временем. Если зависимость зависла, она не должна удерживать запрос бесконечно. Повторять можно только временные ошибки и только с учётом побочных эффектов. Ошибку валидации повтор не исправит. Ограничьте число попыток, общий срок и добавьте задержку; иначе сбой зависимости превращается в лавину повторов.\n\n## Разбор на примере\n\nОтвет клиенту:\n```json\n{\"error\":{\"code\":\"database_unavailable\",\"message\":\"Повторите позже\",\"requestId\":\"req-42\"}}\n```\nЖурнал с тем же requestId содержит тип ошибки и длительность, но не DATABASE_URL. Для внешнего fetch можно передать signal: AbortSignal.timeout(3000).\n\n## Источники и следующий шаг\n\n- [MDN: HTTP](https://developer.mozilla.org/en-US/docs/Web/HTTP)\n- [Node.js: основы](https://nodejs.org/en/learn/getting-started/introduction-to-nodejs)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-errors-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Остановите локальную базу во время запросов. Сделайте ограниченное ожидание и единый формат ошибок. Сопоставьте ответ с журналом.",
          "task": "Остановите локальную базу во время запросов. Сделайте ограниченное ожидание и единый формат ошибок. Сопоставьте ответ с журналом.",
          "content": "## Задание\n\nОстановите локальную базу во время запросов. Сделайте ограниченное ожидание и единый формат ошибок. Сопоставьте ответ с журналом.\n\n## Критерии готовности\n\n- Запрос завершается в установленный срок.\n- В ответе нет stack trace и строки подключения.\n- Валидационные ошибки не уходят в повтор.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-errors-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что следует повторять автоматически?",
          "correctAnswer": 2,
          "options": [
            {
              "text": "Любую ошибку до успеха",
              "feedback": "Неверно. Повторы требуют и временного характера сбоя, и понимания последствий повторной операции."
            },
            {
              "text": "Невалидный JSON",
              "feedback": "Неверно. Повторы требуют и временного характера сбоя, и понимания последствий повторной операции."
            },
            {
              "text": "Ограниченный набор временных сбоев с безопасной семантикой",
              "feedback": "Верно. Повторы требуют и временного характера сбоя, и понимания последствий повторной операции."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-idempotency",
      "label": "14. Повтор без дублей",
      "area": "auth",
      "type": "medium",
      "x": 0.6892941151042167,
      "y": 0.8050389122504071,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-idempotency-reading",
          "kind": "lecture",
          "title": "Повтор без дублей",
          "desc": "Обработать повторную отправку операции.",
          "content": "Пользователь дважды нажал кнопку, клиент повторил POST после тайм-аута, прокси оборвал ответ — всё это обычные сценарии. Идемпотентность означает, что повтор одной операции не умножает её эффект. Ключ должен описывать попытку бизнес-действия, а не генерироваться заново при каждом сетевом повторе.\n\nХраните ключ вместе с пользователем, типом операции, отпечатком входа и результатом. Уникальное ограничение защищает от двух параллельных обработчиков. Тот же ключ с другим телом отклоняется, иначе ошибка клиента вернёт результат чужого по смыслу запроса. Заранее определите срок хранения ключей: после его истечения прежние гарантии уже не действуют.\n\n![Запрос K-42 создаёт заказ 17. Ответ теряется; повтор с тем же ключом возвращает результат существующего заказа.](/course-images/backend-idempotency-v1.webp \"Потерянный ответ не означает, что операция не выполнена. Сервер связывает ключ с операцией и её результатом: повтор возвращает прежний результат без второго заказа. На практике нужны атомарная запись, область действия ключа и проверка совпадения данных запроса.\")\n\n## Разбор на примере\n\nАня отправляет POST /projects с ключом create-project-7. Ответ потерян. Повтор с тем же телом и ключом возвращает тот же projectId. Повтор с названием другого проекта и тем же ключом получает 409. Ключ Бориса не пересекается с ключом Ани.\n\n## Источники и следующий шаг\n\n- [PostgreSQL: учебник](https://www.postgresql.org/docs/current/tutorial.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-idempotency-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте идемпотентное создание проекта. В одной транзакции сохраните ключ и результат; проверьте два параллельных запроса.",
          "task": "Добавьте идемпотентное создание проекта. В одной транзакции сохраните ключ и результат; проверьте два параллельных запроса.",
          "content": "## Задание\n\nДобавьте идемпотентное создание проекта. В одной транзакции сохраните ключ и результат; проверьте два параллельных запроса.\n\n## Критерии готовности\n\n- Создаётся ровно один проект.\n- Ключ ограничен пользователем и операцией.\n- Изменённое тело с прежним ключом отклоняется.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-idempotency-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Где надёжно защититься от двух одновременных одинаковых ключей?",
          "correctAnswer": 0,
          "options": [
            {
              "text": "Уникальным ограничением в базе",
              "feedback": "Верно. Два SELECT могут одновременно не найти ключ. Уникальность арбитрируется базой."
            },
            {
              "text": "Предварительным SELECT без ограничения",
              "feedback": "Неверно. Два SELECT могут одновременно не найти ключ. Уникальность арбитрируется базой."
            },
            {
              "text": "Отключением кнопки",
              "feedback": "Неверно. Два SELECT могут одновременно не найти ключ. Уникальность арбитрируется базой."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-jobs",
      "label": "15. Фоновые задачи",
      "area": "auth",
      "type": "medium",
      "x": 0.6285138043084149,
      "y": 0.8809044527203357,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-jobs-reading",
          "kind": "lecture",
          "title": "Фоновые задачи",
          "desc": "Вынести долгую работу из HTTP-запроса.",
          "content": "Экспорт тысячи задач не должен держать браузерное соединение открытым. Создайте запись задания, верните 202 с jobId и выполняйте работу отдельным worker. Пользователь проверяет состояние: queued, running, succeeded, failed. Результат выдаётся только владельцу задания.\n\nФоновая работа тоже падает: процесс завершился после записи файла, но до отметки succeeded. Поэтому обработчик должен переживать повтор. Нужны лимит попыток, срок аренды задания и способ вернуть зависшие running в обработку. Для первого проекта достаточно таблицы заданий. Очередь сама по себе не гарантирует однократный бизнес-эффект.\n\n## Разбор на примере\n\nСценарий: POST /exports → 202 {jobId}. Worker формирует файл под стабильным ключом export/jobId. Повтор перезаписывает тот же результат, а не создаёт второй экспорт с новым ID. GET /exports/jobId проверяет владельца.\n\n## Источники и следующий шаг\n\n- [PostgreSQL: учебник](https://www.postgresql.org/docs/current/tutorial.html)\n- [Node.js: основы](https://nodejs.org/en/learn/getting-started/introduction-to-nodejs)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-jobs-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте фоновый экспорт задач в JSON. Остановите worker во время обработки и затем запустите снова.",
          "task": "Добавьте фоновый экспорт задач в JSON. Остановите worker во время обработки и затем запустите снова.",
          "content": "## Задание\n\nДобавьте фоновый экспорт задач в JSON. Остановите worker во время обработки и затем запустите снова.\n\n## Критерии готовности\n\n- HTTP-запрос не ждёт завершения экспорта.\n- Зависшее задание можно повторить.\n- Чужой пользователь не получает статус и файл.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-jobs-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Worker может получить задание повторно. Что требуется?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Надёжда на очередь",
              "feedback": "Неверно. Повторная доставка нормальна; бизнес-эффект должен оставаться контролируемым."
            },
            {
              "text": "Обработчик с безопасным повтором",
              "feedback": "Верно. Повторная доставка нормальна; бизнес-эффект должен оставаться контролируемым."
            },
            {
              "text": "Новый jobId при каждом запуске",
              "feedback": "Неверно. Повторная доставка нормальна; бизнес-эффект должен оставаться контролируемым."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-tests",
      "label": "16. Тесты поведения",
      "area": "deploy",
      "type": "large",
      "x": 0.426472201515086,
      "y": 0.7179304082728288,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-tests-reading",
          "kind": "lecture",
          "title": "Тесты поведения",
          "desc": "Доказывать работу сценариев, а не наличие функций.",
          "content": "Разделите быстрые тесты чистых правил и интеграционные тесты с настоящей локальной PostgreSQL. Mock базы не обнаружит ошибку SQL, внешний ключ и гонку транзакций. Тест должен явно задавать исходное состояние, действие и проверяемый результат. Подготовка данных изолируется от других тестов.\n\nВ первую очередь проверьте создание задачи, отказ на плохой ввод, доступ двух пользователей, откат транзакции и повтор операции. Не гонитесь за процентом покрытия: можно выполнить все строки и не проверить ни одного важного результата. Тест, который проходит при намеренном удалении проверки владельца, не защищает доступ.\n\n## Разбор на примере\n\nПример чистого теста в test/validation.test.js:\n```javascript\nimport test from \"node:test\";\nimport assert from \"node:assert/strict\";\nimport {parseTask} from \"../src/validation.js\";\ntest(\"название очищается, ownerId не принимается\", () => {\n  assert.deepEqual(parseTask({title: \"  API  \", ownerId: \"other\"}), {title: \"API\"});\n});\ntest(\"пустое название запрещено\", () => {\n  assert.throws(() => parseTask({title: \"   \"}));\n});\n```\n\n## Источники и следующий шаг\n\n- [Node.js: тесты](https://nodejs.org/api/test.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-tests-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Соберите набор из пяти интеграционных сценариев. Временно сломайте проверку владельца локально и убедитесь, что соответствующий тест падает; затем восстановите код.",
          "task": "Соберите набор из пяти интеграционных сценариев. Временно сломайте проверку владельца локально и убедитесь, что соответствующий тест падает; затем восстановите код.",
          "content": "## Задание\n\nСоберите набор из пяти интеграционных сценариев. Временно сломайте проверку владельца локально и убедитесь, что соответствующий тест падает; затем восстановите код.\n\n## Критерии готовности\n\n- Тесты повторяемы без ручной очистки базы.\n- Чужой доступ проверяется настоящим HTTP-запросом.\n- Намеренная ошибка действительно обнаруживается.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-tests-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что лучше доказывает защиту доступа?",
          "correctAnswer": 2,
          "options": [
            {
              "text": "Тест числа строк файла",
              "feedback": "Неверно. Негативный межпользовательский сценарий проверяет само правило."
            },
            {
              "text": "Успешный вход одного пользователя",
              "feedback": "Неверно. Негативный межпользовательский сценарий проверяет само правило."
            },
            {
              "text": "Запрос пользователя Б к задаче А с проверкой отказа",
              "feedback": "Верно. Негативный межпользовательский сценарий проверяет само правило."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-container",
      "label": "17. Docker без потери данных",
      "area": "deploy",
      "type": "medium",
      "x": 0.3560521074555678,
      "y": 0.7319655238004489,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-container-reading",
          "kind": "lecture",
          "title": "Docker без потери данных",
          "desc": "Собрать воспроизводимый запуск.",
          "content": "Образ фиксирует runtime и файлы приложения, контейнер — конкретный запущенный процесс. Записи во временный слой контейнера не являются надёжным хранилищем. PostgreSQL подключайте к отдельному volume; секреты передавайте при запуске. Не копируйте .env в образ.\n\nДля нашего JavaScript-проекта компилятор не нужен. Копируйте package-файлы перед исходниками, чтобы изменение сервера не сбрасывало кеш зависимостей. Запускайте процесс непривилегированным пользователем. В контейнере сервер слушает 0.0.0.0; это не то же самое, что публиковать порт базы наружу. Публикацию портов определяет конфигурация запуска.\n\n## Разбор на примере\n\n```dockerfile\nFROM node:24-alpine\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci --omit=dev\nCOPY src ./src\nUSER node\nCMD [\"node\", \"src/server.js\"]\n```\nДля воспроизводимого релиза закрепите базовый образ по выбранному digest. В .dockerignore исключите .env, node_modules, .git. Серверу задайте адрес прослушивания через конфигурацию.\n\n## Источники и следующий шаг\n\n- [Docker: начало работы](https://docs.docker.com/get-started/)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-container-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Соберите образ Taskboard и запустите с локальной базой. Пересоздайте контейнер приложения и убедитесь, что задачи сохранились.",
          "task": "Соберите образ Taskboard и запустите с локальной базой. Пересоздайте контейнер приложения и убедитесь, что задачи сохранились.",
          "content": "## Задание\n\nСоберите образ Taskboard и запустите с локальной базой. Пересоздайте контейнер приложения и убедитесь, что задачи сохранились.\n\n## Критерии готовности\n\n- Приложение отвечает через опубликованный локальный порт.\n- Секретов нет в образе и исходниках.\n- Данные PostgreSQL переживают пересоздание её контейнера с тем же volume.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-container-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что хранит данные PostgreSQL независимо от контейнера?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Неизменяемый слой образа",
              "feedback": "Неверно. Данные базы должны находиться в постоянном хранилище, подключаемом новому контейнеру."
            },
            {
              "text": "Отдельный постоянный volume",
              "feedback": "Верно. Данные базы должны находиться в постоянном хранилище, подключаемом новому контейнеру."
            },
            {
              "text": "Кеш npm",
              "feedback": "Неверно. Данные базы должны находиться в постоянном хранилище, подключаемом новому контейнеру."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-delivery",
      "label": "18. Поставка и миграция",
      "area": "deploy",
      "type": "medium",
      "x": 0.276554257145051,
      "y": 0.723445742854949,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-delivery-reading",
          "kind": "lecture",
          "title": "Поставка и миграция",
          "desc": "Проверить релиз до переключения пользователей.",
          "content": "CI повторяет одни и те же проверки для каждого изменения: установка зависимостей, тесты, сборка артефакта. CD переносит проверенный артефакт в окружение. Если пересобрать «то же самое» отдельно в production, вы проверяли уже не тот артефакт. Фиксируйте идентификатор сборки и конфигурацию запуска.\n\nПеред релизом проверьте совместимость миграции с предыдущей версией. Сначала добавьте новое поле, затем выпустите код, и лишь позже удаляйте старое. Откат приложения не возвращает удалённые данные. Для учебного проекта проведите репетицию локально: старая версия → миграция → новая версия → возврат старой версии на расширенной схеме.\n\n## Разбор на примере\n\nМинимальная последовательность: npm ci → node --test → сборка образа → запуск с тестовой базой → HTTP smoke → сохранение версии образа. Проверка smoke создаёт и читает задачу, а не ограничивается /health.\n\n## Источники и следующий шаг\n\n- [Docker: начало работы](https://docs.docker.com/get-started/)\n- [GitHub Actions: основы](https://docs.github.com/en/actions/get-started/understand-github-actions)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-delivery-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Опишите и воспроизведите релиз с добавлением priority. Проверьте рабочий сценарий до и после миграции и подготовьте команду возврата предыдущего образа.",
          "task": "Опишите и воспроизведите релиз с добавлением priority. Проверьте рабочий сценарий до и после миграции и подготовьте команду возврата предыдущего образа.",
          "content": "## Задание\n\nОпишите и воспроизведите релиз с добавлением priority. Проверьте рабочий сценарий до и после миграции и подготовьте команду возврата предыдущего образа.\n\n## Критерии готовности\n\n- Артефакт идентифицируется тегом или digest.\n- Есть проверка создания и чтения задачи.\n- Понятно, какие изменения данных не отменяются откатом кода.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-delivery-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что нужно проверить до отката приложения?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Только цвет интерфейса",
              "feedback": "Неверно. Данные и схема могли измениться независимо от версии приложения."
            },
            {
              "text": "Совместимость старого кода с текущей схемой базы",
              "feedback": "Верно. Данные и схема могли измениться независимо от версии приложения."
            },
            {
              "text": "Количество коммитов",
              "feedback": "Неверно. Данные и схема могли измениться независимо от версии приложения."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-observe",
      "label": "19. Логи и восстановление",
      "area": "deploy",
      "type": "medium",
      "x": 0.19496108774959292,
      "y": 0.6892941151042167,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-observe-reading",
          "kind": "lecture",
          "title": "Логи и восстановление",
          "desc": "Увидеть сбой и восстановить данные на практике.",
          "content": "Пользователь сообщает «задача не сохранилась». Для расследования нужны requestId, маршрут, статус, длительность и версия приложения. Не сохраняйте тело каждого запроса: там могут быть личные данные. Метрики отвечают на общий вопрос «растёт ли доля ошибок», логи — на вопрос о конкретном событии.\n\nИзмеряйте скорость ответа и частоту ошибок; среднее скрывает медленные запросы, поэтому смотрите распределение. Сигнал должен приводить к действию: проверить доступность базы, лимит соединений или выпуск. Резервная копия полезна только после успешного восстановления. В учебном локальном окружении можно сделать дамп самостоятельно; платный сервис для упражнения не нужен.\n\n## Разбор на примере\n\nСобытие:\n```json\n{\"requestId\":\"r7\",\"route\":\"POST /tasks\",\"status\":201,\"durationMs\":24,\"release\":\"v1\"}\n```\nРепетиция восстановления: дамп локальной учебной базы → новая отдельная база → восстановление → сравнение количества проектов и задач → HTTP-чтение. Не восстанавливайте поверх единственной рабочей копии.\n\n## Источники и следующий шаг\n\n- [PostgreSQL: резервное копирование](https://www.postgresql.org/docs/current/backup.html)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-observe-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Добавьте структурированный журнал. Смоделируйте недоступную базу, найдите запрос по ID и восстановите учебный дамп в отдельную базу.",
          "task": "Добавьте структурированный журнал. Смоделируйте недоступную базу, найдите запрос по ID и восстановите учебный дамп в отдельную базу.",
          "content": "## Задание\n\nДобавьте структурированный журнал. Смоделируйте недоступную базу, найдите запрос по ID и восстановите учебный дамп в отдельную базу.\n\n## Критерии готовности\n\n- Журнал не содержит cookie, пароли и названия приватных задач.\n- Сбой можно связать с конкретным запросом.\n- Проверено чтение восстановленных данных.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-observe-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что подтверждает пригодность резервной копии?",
          "correctAnswer": 2,
          "options": [
            {
              "text": "Файл существует",
              "feedback": "Неверно. Сам факт создания файла не доказывает его полноту и восстанавливаемость."
            },
            {
              "text": "У него красивое имя",
              "feedback": "Неверно. Сам факт создания файла не доказывает его полноту и восстанавливаемость."
            },
            {
              "text": "Восстановление в отдельную базу и проверка данных",
              "feedback": "Верно. Сам факт создания файла не доказывает его полноту и восстанавливаемость."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    },
    {
      "id": "backend-v1-release",
      "label": "20. Защита проекта",
      "area": "deploy",
      "type": "medium",
      "x": 0.11909554727966432,
      "y": 0.6285138043084149,
      "locked": true,
      "sections": [
        {
          "id": "backend-v1-release-reading",
          "kind": "lecture",
          "title": "Защита проекта",
          "desc": "Собрать доказательства готовности API.",
          "content": "Финал маршрута — проверяемый проект, который может запустить другой человек. README описывает установку, конфигурацию без секретов, миграции, тестовые аккаунты и основные запросы. Укажите ограничения: какие сценарии не реализованы и какую нагрузку вы действительно проверяли.\n\nПройдите демонстрацию с чистого состояния. Аня создаёт проект и задачу, завершает её; Борис не может её прочитать; перезапуск не теряет запись; повтор создания не создаёт дубль. Затем сломайте зависимость и покажите понятную ошибку. Именно эти проверки объясняют качество backend лучше, чем длинный список технологий. Kubernetes, кеш и микросервисы добавляйте, когда сможете назвать конкретное ограничение текущего решения.\n\n## Разбор на примере\n\nЧек-лист демонстрации: пустая база → миграции → запуск → вход Ани → создание → доступ Бориса → повтор → перезапуск → чтение. Для каждого пункта сохраните команду и ожидаемый статус; секретные значения замените локальными тестовыми.\n\n## Источники и следующий шаг\n\n- [Node.js: тесты](https://nodejs.org/api/test.html)\n- [Docker: начало работы](https://docs.docker.com/get-started/)",
          "tags": [
            "разбор",
            "пример"
          ]
        },
        {
          "id": "backend-v1-release-practice",
          "kind": "code",
          "language": "Markdown",
          "title": "Практика: применить в проекте",
          "desc": "Подготовьте README и короткую демонстрацию Taskboard. Выполните весь сценарий в чистом локальном окружении и перечислите три дальнейших улучшения с причинами.",
          "task": "Подготовьте README и короткую демонстрацию Taskboard. Выполните весь сценарий в чистом локальном окружении и перечислите три дальнейших улучшения с причинами.",
          "content": "## Задание\n\nПодготовьте README и короткую демонстрацию Taskboard. Выполните весь сценарий в чистом локальном окружении и перечислите три дальнейших улучшения с причинами.\n\n## Критерии готовности\n\n- Другой человек запускает проект по README.\n- Есть позитивные и негативные проверки доступа.\n- Дальнейшие технологии привязаны к измеренной проблеме, а не к моде.\n\nЗапустите проверки в своём проекте. В ответ запишите результат и приложите небольшой фрагмент решения или вывод команды. Самопроверка не исполняет ваш код и не заменяет проверку в рабочем окружении.",
          "tags": [
            "практика",
            "самопроверка"
          ]
        },
        {
          "id": "backend-v1-release-quiz",
          "kind": "quiz",
          "title": "Проверка понимания",
          "question": "Что является лучшим основанием добавить кеш?",
          "correctAnswer": 1,
          "options": [
            {
              "text": "Так делают все",
              "feedback": "Неверно. Оптимизация требует измерения и понимания того, когда данные могут устареть."
            },
            {
              "text": "Измеренная задержка повторяющегося чтения и определённые правила устаревания",
              "feedback": "Верно. Оптимизация требует измерения и понимания того, когда данные могут устареть."
            },
            {
              "text": "В проекте мало зависимостей",
              "feedback": "Неверно. Оптимизация требует измерения и понимания того, когда данные могут устареть."
            }
          ],
          "tags": [
            "тест"
          ]
        }
      ],
      "test": null
    }
  ],
  "connections": [
    {
      "from": "start",
      "to": "backend-v1-scope",
      "kind": "root"
    },
    {
      "from": "backend-v1-scope",
      "to": "backend-v1-setup",
      "kind": "child"
    },
    {
      "from": "backend-v1-setup",
      "to": "backend-v1-http",
      "kind": "child"
    },
    {
      "from": "backend-v1-http",
      "to": "backend-v1-server",
      "kind": "child"
    },
    {
      "from": "backend-v1-server",
      "to": "backend-v1-contract",
      "kind": "child"
    },
    {
      "from": "backend-v1-contract",
      "to": "backend-v1-model",
      "kind": "root"
    },
    {
      "from": "backend-v1-model",
      "to": "backend-v1-queries",
      "kind": "child"
    },
    {
      "from": "backend-v1-queries",
      "to": "backend-v1-migrations",
      "kind": "child"
    },
    {
      "from": "backend-v1-migrations",
      "to": "backend-v1-transactions",
      "kind": "child"
    },
    {
      "from": "backend-v1-transactions",
      "to": "backend-v1-pagination",
      "kind": "child"
    },
    {
      "from": "backend-v1-pagination",
      "to": "backend-v1-sessions",
      "kind": "root"
    },
    {
      "from": "backend-v1-sessions",
      "to": "backend-v1-authorization",
      "kind": "child"
    },
    {
      "from": "backend-v1-authorization",
      "to": "backend-v1-errors",
      "kind": "child"
    },
    {
      "from": "backend-v1-errors",
      "to": "backend-v1-idempotency",
      "kind": "child"
    },
    {
      "from": "backend-v1-idempotency",
      "to": "backend-v1-jobs",
      "kind": "child"
    },
    {
      "from": "backend-v1-jobs",
      "to": "backend-v1-tests",
      "kind": "root"
    },
    {
      "from": "backend-v1-tests",
      "to": "backend-v1-container",
      "kind": "child"
    },
    {
      "from": "backend-v1-container",
      "to": "backend-v1-delivery",
      "kind": "child"
    },
    {
      "from": "backend-v1-delivery",
      "to": "backend-v1-observe",
      "kind": "child"
    },
    {
      "from": "backend-v1-observe",
      "to": "backend-v1-release",
      "kind": "child"
    }
  ],
  "areas": [
    {
      "id": "infra",
      "label": "Первый работающий HTTP-сервис",
      "color": "#6C5CE7",
      "cx": 0.2807968978321703,
      "cy": 0.28079689783217027
    },
    {
      "id": "data",
      "label": "Данные и корректность",
      "color": "#00B894",
      "cx": 0.7192031021678298,
      "cy": 0.2807968978321703
    },
    {
      "id": "auth",
      "label": "Пользователи и надёжность",
      "color": "#E17055",
      "cx": 0.7192031021678298,
      "cy": 0.7192031021678297
    },
    {
      "id": "deploy",
      "label": "Проверки и выпуск",
      "color": "#00CEC9",
      "cx": 0.2807968978321703,
      "cy": 0.7192031021678298
    }
  ],
  "notes": []
}
