Перейти до вмісту
Статті

.default(false) у схемі Drizzle видалив мою продакшн базу даних. Постмортем.

Один рядок .default(false) у схемі Drizzle спричинив перебудову таблиці SQLite, яка каскадом знесла продакшн базу даних. Ось постмортем.

Фрагмент diff у файлі auth.ts, де до колонки emailVerified додано .default(false)

Отже, минулої пʼятниці ввечері я викотив те, що виглядало як найнудніша зміна схеми за всю мою карʼєру. Один рядок, одне значення за замовчуванням, міграція, яку drizzle-kit згенерував без жодних заперечень. Я натиснув кнопку. Через пʼять хвилин більшої частини моєї бази даних не стало.

Якщо ви використовуєте Drizzle поверх SQLite чи Cloudflare D1, і хоч одна з ваших таблиць має ON DELETE CASCADE — цей допис для вас.

TL;DR: не існує безпечного способу в рамках фреймворку застосувати міграції, які запускають перебудову таблиці SQLite. Виправлення, на яке всі посилаються в інтернеті, для цього випадку не працює, а єдине, що дійсно зберігає дані, вимагає повністю оминути і drizzle-kit, і wrangler. Я зробив репозиторій для відтворення , щоб ви могли самі переконатися за дві хвилини. Репозиторій і повна матриця — в кінці.

Оригінальний інцидент

Коротка версія

У мене Nuxt + Cloudflare D1 + Drizzle + Better Auth. Застосунок — це опенсорсний, self-hosted AI-чат, де користувачі можуть використовувати власні API-ключі (BYOK) для роботи з кількома провайдерами й різними моделями. У схемі users стоїть нагорі, а від неї через ON DELETE CASCADE звисає з півдесятка дочірніх таблиць — chats, messages, accounts, sessions і решта звичного набору таблиць auth та самого застосунку. Нічого особливого.

Реальний користувач повідомив мені про проблему з авторизацією. Я делегував це Claude. Виправлення стосувалося функціональності Better Auth. Коли Claude звірив документацію, схему auth, яку автоматично згенерував better auth CLI, і мою реальну схему, він виявив, що я забув додати .default(false) до колонки emailVerified у таблиці users. Це була лише пропозиція. Я не звинувачую Claude, бо відповідальність за подальші дії була на мені.

Я додав запропоноване. Запустив drizzle-kit generate. Отримав файл міграції, який побіжно переглянув і схвалив. Він містив «дванадцятикроковий» ритуал перебудови таблиці SQLite — PRAGMA foreign_keys=OFF, створення нової таблиці, копіювання даних, видалення старої таблиці, перейменування, PRAGMA foreign_keys=ON. Я вже бачив цей патерн раніше. Виглядало нормально. Власне, саме так і радить документація SQLite.

Я перевірив це локально. Створив PR, застосував його на preview через GitHub Actions на CI і вручну протестував preview-інстанс воркера. Потім змерджив PR і викотив на продакшн. Оскільки PR стосувався виправлення авторизації, я вручну протестував кроки користувача на всіх середовищах однаково: вийти, увійти, створити новий чат, отримати редірект на сторінку нового чату з динамічним слагом і отримати відповідь від AI. Я не заходив на сторінку історії з усіма нещодавніми чатами, бо вона не була повʼязана з самим виправленням. Нічого не здавалося підозрілим.

Відновлення відбулося через D1 Time Travel — бінарний пошук по закладках (bookmarks) за таймстемпом, поки кількість рядків не повернулася до норми. Із Claude це зайняло приблизно 5 хвилин на кожну базу даних. Я вижив. Історія — в постмортемі besidka/docs/postmortems/2026-05-15-postmortem-d1-cascade-wipe.md . Я вирішив, що урок такий: «PRAGMA foreign_keys=OFF не переживає HTTP-транспорт D1, де кожна інструкція виконується окремо (per-statement); стеж за своїми міграціями». Закрив ноутбук.

А потім у неділю вирішив, що варто це таки довести.

Першопричина — зміна схеми змусила перебудувати таблицю

SQLite не вміє ALTER TABLE ... ALTER COLUMN ... SET DEFAULT. Єдиний спосіб змінити значення за замовчуванням для колонки в SQLite — перебудувати всю таблицю. Drizzle автоматизує це стандартним «12-кроковим» патерном SQLite:

-- Step 1: try to suppress cascades
PRAGMA foreign_keys=OFF;

-- Step 2: new table with new schema
CREATE TABLE __new_users (...);

-- Step 3: copy data over
INSERT INTO __new_users SELECT ... FROM users;

-- Step 4: destroy old table  <- BOMB
DROP TABLE users;

-- Step 5: swap names
ALTER TABLE __new_users RENAME TO users;

-- Step 6: re-enable
PRAGMA foreign_keys=ON;

Цей самий патерн запускає БУДЬ-ЯКА з таких змін схеми в SQLite:

  • Додавання / зміна / видалення DEFAULT колонки
  • Зміна типу колонки, nullability чи обмеження
  • Перейменування або видалення колонки
  • Зміна первинного ключа
  • Зміна або видалення зовнішнього ключа

Тож один цілком безневинний на вигляд .default(false) запустив той самий SQL-патерн, що й «видалити колонку».

Що таке PRAGMA і чому вона мене не врятувала?

PRAGMA — це специфічна для SQLite команда, яка керує налаштуваннями движка. Приклади:

  • PRAGMA foreign_keys = ON|OFF — вмикає/вимикає перевірку зовнішніх ключів (а отже, і поведінку CASCADE)
  • PRAGMA journal_mode = WAL — встановлює режим write-ahead-log
  • PRAGMA synchronous = NORMAL — керує довговічністю запису

Пастка в тому, що PRAGMA foreign_keys — це налаштування на рівні зʼєднання. Воно діє лише в межах того самого зʼєднання і зберігається лише на час цього зʼєднання.

Я припускав, що у ванільному SQLite (наприклад, better-sqlite3, один вбудований файл) уся міграція виконується в одному зʼєднанні, тож PRAGMA foreign_keys=OFF на самому початку діє й на DROP TABLE чотирма інструкціями пізніше. Каскади придушені. Безпечно.

У Cloudflare D1 файл міграції розбивається за маркерами --> statement-breakpoint, і кожна інструкція виконується як окремий HTTP API-виклик до edge-сервісу D1. Чи ділять ці виклики одне зʼєднання — залежить від реалізації і не гарантується. PRAGMA foreign_keys=OFF в інструкції 1 фактично ніяк не вплинула на інструкцію 4 (DROP TABLE users). Коли D1 виконав DROP TABLE, зовнішні ключі були увімкнені, і каскад спрацював по кожній дочірній таблиці, що посилалася на users.

Ось ключовий факт, на якому все тримається: транспортна межа D1 ламає контракт PRAGMA, на який спирається безпека 12-крокового патерну перебудови SQLite.

Чому users вижили, а все інше загинуло?

Найбільш контрінтуїтивна частина. Погляньте на порядок:

-- Empty new table:
CREATE TABLE __new_users (...);

-- Users data is NOW also in __new_users
INSERT INTO __new_users SELECT * FROM users;

-- Cascade kills children, but __new_users is untouched
DROP TABLE users;

-- __new_users becomes users
ALTER TABLE __new_users RENAME TO users;

На момент виконання DROP TABLE users рядки користувачів уже були скопійовані в __new_users. Тож коли оригінальну таблицю users видалили:

  • Рядки в users «зникли» — але ті самі рядки й далі існували в __new_users.
  • Кожна дочірня таблиця-каскад отримала DELETE FROM <child> WHERE user_id IN (SELECT id FROM users) — ці DELETE виконувались проти рядків у таблиці, яку саме видаляли, і спрацювали до завершення видалення. Усі дочірні дані знесло.
  • Перейменування на кроці 5 повернуло дані users під назвою users, але дочірні дані, знесені каскадом, не відновлюються — жодної їхньої резервної копії ніде в міграції немає.

Дані users вижили, бо їх скопіювали. Все інше загинуло, бо каскад спрацював на таблиці, яку саме видаляли, без жодного кроку копіювання, що міг би це захистити.

Чи могло це статися на Postgres?

Я багато разів чув, що бази SQLite не для продакшну. А залишився я на SQLite через інфраструктуру Cloudflare. Мій застосунок розгорнутий як безсерверний Cloudflare Worker, файлове сховище — бакет Cloudflare R2, а база даних — D1, теж SQLite під капотом. Обираючи базу даних, я хотів залишитися в межах інфраструктури CF: виклики Worker↔D1 йдуть через bindings по внутрішній мережі Cloudflare замість публічного HTTPS, а з read-реплікацією репліки можуть розташовуватися близько до воркера. Ви, ймовірно, вже налаштовували базу даних і застосунок через Docker і Nginx на VPS — мій сетап простіший: немає сервера, який треба підтримувати, немає пулу зʼєднань, яким треба керувати, а обчислення, база даних і сховище живуть на одній платформі й спілкуються через bindings.

Тож неділя була ідеальним днем, щоб зʼясувати, чи це проблема D1, архітектурна проблема SQLite, чи інші типи баз даних мають схожі проблеми.

Відтворення

Я зібрав невеликий репозиторій : три майже ідентичні схеми (SQLite, MySQL і Postgres), кожна з users, chats(user_id CASCADE), messages(chat_id CASCADE). Крок перший створює схему, застосовує початкову міграцію і засіває 3 users + 6 chats + 18 messages. Крок другий додає .default(false) до колонки в users і застосовує зміну. Наприкінці перевіряються кількості рядків. Якщо каскад спрацьовує, дочірні дані обнуляються.

Почав з простого: просто drizzle-kit migrate на локальному SQLite. Це мав би бути «безпечний» шлях, правда? Одне зʼєднання, вбудований better-sqlite3 і жодного транспорту D1. Інший баг, інший сценарій, постмортем підтверджено.

[verify] sqlite     users=3 chats=0 messages=0

Ой.

Отже, баг не специфічний для D1. Тут drizzle-kit. Локальний SQLite з better-sqlite3. Жодного HTTP ніде. Каскад все одно спрацьовує.

Чому? Тому що PRAGMA foreign_keys=OFF мовчки ігнорується, якщо встановити її всередині відкритої транзакції. Документація SQLite прямо про це каже — в абзаці, який я, звісно ж, теж ніколи не читав. А мігратор drizzle-kit загортає кожен файл міграції в транзакцію. Тож найперша інструкція перебудови, єдине призначення якої — запобігти каскаду, не робить нічого.

Скрипт міграції далі спокійно видаляє батьківську таблицю. FK-перевірка ввімкнена весь цей час; каскад спрацьовує, дочірні дані гинуть. PRAGMA foreign_keys=ON унизу — це SQL-еквівалент замикання вхідних дверей уже після того, як будинок обчистили.

Це пояснює й D1, просто через інший механізм. Транспорт D1 надсилає кожну інструкцію як окремий HTTP-виклик, тож навіть поза транзакцією PRAGMA ніколи не досягає інструкції DROP, бо вони на різних зʼєднаннях. Два зовсім різні баги, той самий фатальний результат.

«Просто використай defer_foreign_keys»

Тож я почав клікати по всьому підряд. Документація NuxtHub посилається на сторінку Cloudflare про defer-foreign-keys , яка пропонує PRAGMA defer_foreign_keys = on як обхідний шлях для дивної поведінки FK всередині міграцій D1. Виправлення звучить очевидно: нехай міграція використовує defer_foreign_keys = on замість foreign_keys = OFF. Тоді воно працює всередині транзакцій. Готово, викочуємо. Чому ми взагалі ще про це говоримо?!

Тож я написав однорядковий патчер: читає останній файл міграції, робить regex-заміну PRAGMA, прогонює пропатчений SQL через будь-який інструмент застосування міграцій, який захочете, і після цього відновлює оригінальний файл, щоб git залишався чистим. Додав тестові команди для кожного шляху застосування. Скинути, запустити, перевірити.

[verify] sqlite     users=3 chats=0 messages=0

Спробував wrangler d1 migrations apply --local. Те саме. Спробував wrangler d1 execute --file --local. Те саме. Навіть спробував загорнути все в явний BEGIN; PRAGMA defer_foreign_keys = on; ... COMMIT; через better-sqlite3, взагалі без фреймворку. Те саме.

Я хвилину сидів і перетравлював це. А потім пішов і нарешті прочитав документацію SQLite про defer_foreign_keys, як людина, що вчиться на своїх помилках. І там, у другому абзаці, пояснюється, що defer_foreign_keys відкладає перевірку обмежень FK — тобто дозволяє тимчасово порушити обмеження, якщо ви виправите це до COMMIT. Каскадні дії вона не відкладає — і ніколи не мала. ON DELETE CASCADE спрацьовує в ту мить, коли зникає батьківський рядок. Жодна PRAGMA цього не зупиняє, крім foreign_keys = OFF, яку можна встановити тільки поза транзакцією — а це саме те, чого не можна зробити всередині жодного з інструментів застосування міграцій, які нас цікавлять.

Документація Cloudflare і документація NuxtHub обидві правильні для свого справжнього сценарію (порядок валідації обмежень під час seed-подібних операцій). Вони просто не допомагають тут, а сама назва issue вводить в оману — створюється враження, що допомагають. Issue в Drizzle, що на них посилається, успадковує те саме непорозуміння.

На рівні PRAGMA цей баг не виправити. Магічного слова немає.

Що насправді працює (і чому це не має значення)

Для повноти картини я зробив ще один варіант. Одне зʼєднання better-sqlite3, один виклик db.exec() на весь вміст файлу міграції, без транзакції, без розбиття по інструкціях:

const conn = new Database(file);

conn.exec(migrationFileContent); // all 6 statements, one call

conn.close();

Нарешті!

[verify] sqlite     users=3 chats=6 messages=18

Дані вижили. Бо db.exec() не загортає SQL у BEGIN/COMMIT, у зʼєднанні better-sqlite3 FK за замовчуванням вимкнені, DROP не каскадує, перейменування завершується, а завершальна PRAGMA foreign_keys=ON повертає все як було. Міграція, згенерована Drizzle, коректна сама собою. Ламають її саме інструменти застосування міграцій.

Це ще й абсолютно марно для продакшну:

  • Це саморобний скрипт, а не фіча drizzle-kit чи wrangler.
  • Він оминає власне відстеження міграцій Drizzle (__drizzle_migrations). Мені довелося вручну вставляти хеші, щоб стан залишався узгодженим.
  • Немає атомарного відкату. Якщо інструкція 3 падає, у вас частковий стан і сумний on-call.
  • Це не працює на D1. HTTP API не має ендпоінту «надіслати файл одним пакетом». Усі варіанти через wrangler однаково розбивають файл на окремі інструкції.

Тож робоче рішення існує в тому сенсі, в якому існує одноколісний велосипед. Технічно на ньому можна доїхати на роботу. Ви цього не робитимете.

Повна матриця

Я загорнув усе це в pnpm matrix, щоб будь-хто міг відтворити за дві хвилини. Ось що вона виводить:

Вивід pnpm matrix: усі способи застосування міграцій на SQLite, крім одного, знесли дочірні таблиці

Девʼять шляхів з десяти втрачають дані. Десятий вимагає оминути фреймворк. MySQL і Postgres це не зачіпає, бо їхній ALTER COLUMN ... SET DEFAULT — це зміна метаданих на місці однією інструкцією: без перебудови, без DROP, без каскаду.

Що варто робити насправді

Репозиторій демонструє баг. Він його не вирішує. Не існує жодного розумного скрипта, який можна взяти на озброєння, щоб безпечно робити це через drizzle-kit чи wrangler сьогодні. Стратегії, які справді працюють, діють на рівні вище за сам фреймворк:

  • Не запускайте перебудову взагалі. У Drizzle є $defaultFn(() => false), яка встановлює значення в момент вставки в JavaScript, а не в SQL. Жодного ALTER COLUMN, жодної перебудови, жодного ризику міграції. Для застосунків на SQLite з дочірніми таблицями-каскадами це має бути ваша політика за замовчуванням. Ставтеся до значень за замовчуванням на рівні SQL як до привілею, на який заслуговують лише таблиці без FK-звʼязків.
  • Ця порада діє ширше, ніж здається, бо перебудову запускає будь-що з такого: додавання чи видалення значення за замовчуванням, зміна типу колонки, зміна nullability, зміна чи видалення зовнішнього ключа, перейменування колонки або зміна первинного ключа. Список «безпечних» змін схеми для таблиць SQLite з дочірніми каскадами — це приблизно «додати абсолютно нову колонку без значення за замовчуванням і без обмежень», і, по суті, все.
  • Перед застосуванням проженіть кожну згенеровану міграцію SQLite через grep на DROP TABLE. Зробіть це pre-commit хуком, перевіркою в CI або стікером на моніторі. Якщо DROP TABLE зʼявляється на таблиці з FK-дочірніми таблицями, ця міграція деструктивна, доки не доведено протилежне. У продакшні це означає звірку з повною схемою. У маленькому проєкті сам grep ловить усе.
  • Саме для D1: перед кожним застосуванням із --remote робіть закладку Time Travel. Це wrangler d1 time-travel info <db>, закладка — одна вставка з буфера обміну, і це різниця між 30-секундною помилкою і 30-хвилинним відновленням. Зберігайте її в повідомленні того самого коміту, що й деплой. У дашборді цієї фічі не видно; якщо ви про неї не знаєте, дізнаєтесь на власних граблях.
  • Якщо вам справді необхідно зробити перебудову таблиці з дочірніми каскадами, пишіть міграцію вручну: спочатку видаліть обмеження FK, зробіть перебудову, потім додайте обмеження FK назад. Виснажливо. Нудно. Не те, що drizzle-kit згенерує за вас. Але це єдиний спосіб справді бути в безпеці через наявні інструменти застосування міграцій.

Наостанок

Найбільше в цьому багу мене турбує те, наскільки респектабельно виглядає кожен його окремий шматочок. Drizzle генерує SQL-патерн, який рекомендує сама документація SQLite. Drizzle-kit загортає міграції в транзакції заради атомарності, і це правильно у 99% випадків. D1 розбиває інструкції по HTTP, бо саме так будують розподілений SQL-сервіс. У PRAGMA foreign_keys SQLite є свої специфічні правила області дії, бо зміна семантики FK посеред транзакції сама була б граблями. Git-хуки на коміт і пуш. Ревʼю Codex під час відкриття PR. Застосування міграцій на CI. Різні воркери й інстанси D1 для продакшну (при мерджі PR у main) і preview (під час відкриття PR). Кожен окремий шматочок можна виправдати. Ручне тестування зробив я особисто. А взаємодія всього цього — бомба, що знищує базу даних.

Якщо ви на цьому стеку і читаєте це — наступний крок: запустіть матрицю на своїй машині. Потім відкрийте кожну міграцію SQLite Drizzle, яку ви колись згенерували, і проженіть по них grep -l "DROP TABLE". Потім зробіть закладку.

На щастя, це побічний проєкт. Я — єдиний мейнтейнер і відповідальна особа. У мене близько 100 користувачів і близько 1000 чатів. Але цей проєкт важливий для мене, тому я поставився до цього серйозно й сподіваюся рости як інженер. Я пишу цей постмортем, щоб поділитися ним з вами.

Репозиторій: https://github.com/besidka/besidka

Загалом, ось так я провів пʼятницю та неділю. Сподіваюся, чиясь продакшн база досі жива — моя могла б і не вціліти.

PS: Наостанок я запитав у Claude (Opus 4.7, якого використовували для пропозиції і ревʼю змін), чому він допустив, щоб це сталося:

Мені справді шкода.
Відповідь Claude Code: «Мені справді шкода. Це моя провина — я згенерував міграцію з DROP TABLE users»