backend · starter · 4d
Todo CRUD на SQLite
Твой второй бэкенд: API для todos, где задачи живут в SQLite, а не в памяти — так они переживают перезапуск, и ты узнаёшь реальную цену хранения.
Результат
Todos API на Node + SQLite (GET/POST/PATCH/DELETE) с файловым хранением, валидацией ввода и правильными статус-кодами — самый маленький честный бэкенд, который можно деплоить.
Этапы
0/5 · 0%- 01Схема и миграция
Создай файл SQLite и таблицу todos миграцией, а не ручной командой в sqlite3, которую забудешь перезапустить. У таблицы id (INTEGER PRIMARY KEY AUTOINCREMENT), title (TEXT NOT NULL), done (INTEGER 0/1), created_at (TEXT). Миграция — .sql-файл, применяемый при старте если ещё не применён, так что свежий клон или перезапуск всегда дают одну схему. Используй таблицу миграций или проверку версии чтобы миграция не запускалась дважды. Докажи: удали .db-файл, перезапусти, GET /todos возвращает пустой массив — миграция автоматически пересоздала таблицу.
Критерии готовности- SQL-миграция создаёт таблицу todos при первом старте; рестарт с существующим .db не перезапускает её и не дублирует строки.
- Удаление .db и рестарт даёт 200 с пустым списком todos — схема пересоздаётся автоматически.
Самопроверка
Покажи файл миграции и тест удаления-перезапуска. Ревьюер проверяет идемпотентность миграции и что .db — файл, а не :memory:.
- 02Создание и список todos
Подключи POST /todos и GET /todos к реальной базе параметризованными запросами — никогда не интерполируй ввод пользователя в SQL строкой. POST валидирует title (непустая строка, макс 200 символов) через zod, вставляет параметризованным INSERT и возвращает 201 с созданной строкой включая сгенерированный базой id. GET отдаёт все todos по created_at убыв. На отсутствие или пустой title — 400, не 500, и тело 400 называет проблемное поле. Докажи закрытость SQL-инъекции: POST заголовка `' OR 1=1 --` и покажи что он сохранён как буквальная строка, а не исполнен.
Критерии готовности- POST /todos с валидным title → 201 со строкой и id из БД; без/пустой title → 400 с ошибкой поля; заголовок с SQL-нагрузкой хранится буквально.
- GET /todos возвращает все строки из SQLite по created_at, а не из массива в памяти.
Самопроверка
Покажи тест SQL-инъекции (буквальное хранение) и 400 vs 201. Ревьюер проверяет параметризованность запросов (без шаблонных строк с вводом).
- 03Чтение, обновление, удаление одного todo
Замкни CRUD-цикл маршрутами одного ресурса: GET /todos/:id возвращает один todo или 404, PATCH /todos/:id обновляет title/done (валидируя тело так же как POST), DELETE /todos/:id удаляет. 404 означает отсутствие id — не 400, потому что запрос корректен, но адрес неверный. PATCH частичный (любое поле может быть), но если title есть — он должен быть непустым. После DELETE GET того же id — 404. Все обработчики async на параметризованных запросах; после каждой операции изменение видно через GET /todos.
Критерии готовности- GET /todos/:id → 200 или 404, PATCH валидирует и даёт 200, DELETE → 204/200 и последующий GET — 404.
- PATCH с пустым title → 400, не 200; операция над несуществующим id → 404, не 500.
Самопроверка
Покажи PATCH пустой title → 400 и несуществующий id → 404. Ревьюер проверяет частичность PATCH и параметризованность запросов.
- 04Докажи хранение
Докажи что todos переживают перезапуск — весь смысл SQLite. Останови сервер, снова запусти, GET /todos возвращает те же строки с теми же id. Добавь ещё один todo и покажи что его id — следующий авто-инкремент, не сброшенный счётчик. Объясни почему массив в памяти всё потерял бы: куча процесса исчезла, остался только файл. В качестве стретча покажи что будет если .db на read-only файловой системе — сервер должен упасть при старте с понятной ошибкой, а не молча отдавать пустые списки.
Критерии готовности- Остановка и перезапуск сохраняют все todos; новый todo получает следующий авто-инкремент id, а не снова 1.
- Ты можешь объяснить почему in-memory потерялось бы и что гарантирует файл.
Самопроверка
Покажи сохранение строк при рестарте и инкремент следующего id. Ревьюер проверяет файловый .db и объяснение куча vs файл.
- 05Бюджет валидации и ошибки
Сделай обработку ошибок намеренной, не случайной. Каждый 400 называет проблемное поле и ограничение ('title: must be non-empty string ≤200'), не generic 'bad request'. Каждый 404 говорит какой id не найден. Ни один обработчик не бросает необработанное исключение в 500 — оборачивай вызовы БД и возвращай правильную ошибку. Добавь крошечный лог запросов (метод, путь, статус, длительность) чтобы видеть что есть валидационные (400) vs отсутствующие (404) vs серверные (500). Измерь: запусти 10 последовательных POST с чередованием валидных/невалидных title и убедись что последовательность статусов 201,400,201,400… с правильными телами.
Критерии готовности- Все тела 400 называют поле и ограничение; все 404 называют отсутствующий id; ни один валидный запрос не отдаёт 500.
- Последовательность 10 валидных/невалидных запросов даёт ожидаемое чередование 201/400 с правильными телами, лог различает 400 vs 404.
Самопроверка
Покажи тело 400 с полем и 404 с id и последовательность 10 запросов. Ревьюер проверяет отсутствие утечки необработанного исключения в 500.
Стартер
fallowlone/skein-projects
projects/todo-crud-sqlite
- README.md
- src/todos.ts
- test/todos.test.ts
npx degit fallowlone/skein-projects/projects/todo-crud-sqlite todo-crud-sqlite Реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Форкни репозиторий и запушь свою работу — workflow grade прогонит тесты и статические проверки на твоих раннерах.
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Корректность SQL и защита от инъекции | Собирает SQL конкатенацией строк с вводом пользователя; заголовок с кавычкой ломает запрос или инжектит. | Все запросы параметризованы (? плейсхолдеры); заголовок с SQL-нагрузкой хранится буквально, не исполняется. | Может объяснить почему параметризованные запросы — не просто экранирование и где даже они не спасут (например, динамическое имя колонки ORDER BY требует allowlist). |
| Валидация и статус-коды | Отсутствие title даёт 500 или generic 400; 404 vs 400 путаются. | 400 называет поле и ограничение, 404 — отсутствующий id, они никогда не путаются. | Тело 400 машинно-читаемо (поле + ограничение) чтобы клиент мог показать ошибку формы без парсинга; PATCH как PUT уважает семантику идемпотентности. |
| Хранение и миграции | Todos в памяти; рестарт теряет их. Миграции нет — схема создана вручную. | Файл SQLite хранит todos между рестартами; файл миграции идемпотентно создаёт схему при первом старте. | Может рассуждать что ломается при read-only ФС или когда два процесса открывают один файл (блокировки SQLite) и зачем таблица миграций кроме IF NOT EXISTS. |
Эталонный разбор (спойлер)
Почему SQLite для старта: это один файл, без сервера, без сети, без учеток. Долговечность — просто 'байты на диске', простейшее хранение о котором можно рассуждать до WAL, репликации или пулов соединений Postgres.
Параметризованные запросы vs интерполяция строк: `db.prepare('INSERT INTO todos (title) VALUES (?)').run(title)` шлёт SQL и значение разными каналами; значение никогда не парсится как SQL. Конкатенация `... VALUES ('${title}')` парсит заголовок как SQL и ломается на кавычках или инъекции.
Сделай по-сеньорски
- Добавь UNIQUE-ограничение на title на уровне БД и возвращай 409 Conflict на дубликате с понятной ошибкой поля.
- Добавь поисковый параметр ?q= для фильтрации todos по подстроке title через параметризованный LIKE без инъекции.